Files
kandev/apps/backend/internal/authz/roles.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

140 lines
4.7 KiB
Go

package authz
// OrgRole is the instance-wide role carried on the user record. It grants org
// scopes outright and supplies the default workspace role used on workspaces
// that are visible to the whole organization.
//
// The `owner` role and the `org.delete` scope described in
// docs/specs/auth/requirements/roles-and-scopes.md arrive with multi-tenancy: without an
// org entity there is nothing to own or delete, and shipping an unreachable
// role would only clutter the role picker.
type OrgRole string
const (
// OrgRoleAdmin manages users, org settings, and shared configuration. It
// is NOT a reach role: an admin reaches a workspace the same way anyone
// else does, through the unit tree, and never reaches one sitting in
// someone's personal unit.
OrgRoleAdmin OrgRole = "admin"
// OrgRoleMember is a regular contributor.
OrgRoleMember OrgRole = "member"
// OrgRoleGuest holds no org scopes and reaches only workspaces they are an
// explicit member of, never one reached through the unit tree.
OrgRoleGuest OrgRole = "guest"
)
// WorkspaceRole is the per-workspace role, held either by an explicit
// workspace_members row or inherited from membership of a unit above the one
// the workspace sits in. Where both apply the stronger of the two wins.
type WorkspaceRole string
const (
WorkspaceRoleOwner WorkspaceRole = "owner"
WorkspaceRoleCollaborator WorkspaceRole = "collaborator"
WorkspaceRoleViewer WorkspaceRole = "viewer"
// WorkspaceRoleNone means the workspace is unreachable.
WorkspaceRoleNone WorkspaceRole = ""
)
// NormalizeOrgRole coerces an unknown or empty stored value to the least
// privileged real role. Unknown input must never widen access.
func NormalizeOrgRole(value string) OrgRole {
switch OrgRole(value) {
case OrgRoleAdmin:
return OrgRoleAdmin
case OrgRoleMember:
return OrgRoleMember
case OrgRoleGuest:
return OrgRoleGuest
default:
return OrgRoleGuest
}
}
// NormalizeWorkspaceRole coerces an unknown stored value to no access.
func NormalizeWorkspaceRole(value string) WorkspaceRole {
switch WorkspaceRole(value) {
case WorkspaceRoleOwner:
return WorkspaceRoleOwner
case WorkspaceRoleCollaborator:
return WorkspaceRoleCollaborator
case WorkspaceRoleViewer:
return WorkspaceRoleViewer
default:
return WorkspaceRoleNone
}
}
// IsAssignableWorkspaceRole reports whether a role may be set through the
// member API. Owner is reached by transfer, not by assignment.
func IsAssignableWorkspaceRole(role WorkspaceRole) bool {
return role == WorkspaceRoleCollaborator || role == WorkspaceRoleViewer
}
// orgRoleScopes maps an org role to the org scopes it grants.
var orgRoleScopes = map[OrgRole]Set{
OrgRoleAdmin: NewSet(ScopeOrgMembersManage, ScopeOrgSettingsManage, ScopeOrgConfigManage, ScopeUnitManage),
OrgRoleMember: NewSet(),
OrgRoleGuest: NewSet(),
}
// workspaceRoleScopes maps a workspace role to the workspace scopes it grants.
//
// session.exec is deliberately absent from viewer and present on collaborator:
// prompting an agent is bounded by that agent's own permissions, a shell in the
// worktree is not, so reading a transcript must never imply a shell.
var workspaceRoleScopes = map[WorkspaceRole]Set{
WorkspaceRoleOwner: NewSet(
ScopeWorkspaceRead, ScopeWorkspaceManage, ScopeTaskWrite,
ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec,
ScopeRepositoryManage, ScopeSecretManage, ScopeMemberManage,
),
WorkspaceRoleCollaborator: NewSet(
ScopeWorkspaceRead, ScopeTaskWrite,
ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec,
),
WorkspaceRoleViewer: NewSet(ScopeWorkspaceRead),
WorkspaceRoleNone: NewSet(),
}
// OrgScopes returns the org scopes granted by an org role.
func OrgScopes(role OrgRole) Set { return copySet(orgRoleScopes[role]) }
// WorkspaceScopes returns the workspace scopes granted by a workspace role.
func WorkspaceScopes(role WorkspaceRole) Set { return copySet(workspaceRoleScopes[role]) }
// AllScopes returns every scope both role tables can grant. Used by the
// synthetic (auth-disabled) identity, which must behave exactly as the
// pre-auth single user did.
func AllScopes() Set {
all := NewSet()
for _, def := range registry {
all[def.Scope] = struct{}{}
}
return all
}
func copySet(src Set) Set {
out := make(Set, len(src))
for scope := range src {
out[scope] = struct{}{}
}
return out
}
// workspaceRoleRank orders the workspace roles so they can be combined by
// maximum. The zero value is the weakest, so an unknown role never outranks a
// real one.
func workspaceRoleRank(role WorkspaceRole) int {
switch role {
case WorkspaceRoleOwner:
return 3
case WorkspaceRoleCollaborator:
return 2
case WorkspaceRoleViewer:
return 1
default:
return 0
}
}