Files
kandev/apps/backend/internal/authz/resolve.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

137 lines
4.7 KiB
Go

package authz
// Subject is the caller, as far as authorization is concerned.
type Subject struct {
UserID string
OrgRole OrgRole
// OrgID is the caller's tenant, empty when organizations are off.
OrgID string
// TenancyEnforced mirrors features.multiTenancy. When it is on, a subject
// with no org is DENIED rather than treated as org-less: an account that
// somehow escaped the migration must not become a key to every tenant.
TenancyEnforced bool
// Unscoped marks a caller that predates or bypasses per-user scoping: an
// internal caller (event bus, pollers, schedulers) or the synthetic
// identity injected while authentication is disabled. Such a caller gets
// everything, which is exactly today's behavior.
Unscoped bool
}
// WorkspaceRef is the workspace state authorization needs.
type WorkspaceRef struct {
OwnerID string
// OrgID is the workspace's tenant, empty when organizations are off.
OrgID string
// InheritedRole is the highest role the caller holds on any unit that is
// an ancestor of the workspace's unit, including that unit itself. It is
// WorkspaceRoleNone when they are a member of none of them.
InheritedRole WorkspaceRole
// MemberRole is a direct grant recorded against this workspace alone. It
// can only raise the result, never lower it.
MemberRole WorkspaceRole
}
// Decision is the resolved access for one (subject, workspace) pair.
type Decision struct {
Role WorkspaceRole
Scopes Set
}
// CanRead reports whether the caller reaches the workspace at all. A false
// result must surface as 404, never 403: existence is not disclosed.
func (d Decision) CanRead() bool { return d.Scopes.Has(ScopeWorkspaceRead) }
// Has reports whether the decision grants a scope.
func (d Decision) Has(scope Scope) bool { return d.Scopes.Has(scope) }
// ResolveWorkspace is the only place workspace permissions are derived.
//
// Roles combine by maximum. Nothing subtracts, so no grant can lower what a
// unit already gives: narrowing is done by placing a workspace in a unit with
// fewer members, which keeps the rule to one sentence and keeps a permission
// question answerable by reading the tree.
func ResolveWorkspace(subject Subject, ref WorkspaceRef) Decision {
// The tenant boundary is checked first and is absolute. It is not a
// permission level that a role can outrank: a workspace in another org is
// not visible to anybody, including an org admin or an instance operator.
if crossOrg(subject, ref) {
return decide(WorkspaceRoleNone)
}
switch {
case subject.Unscoped:
return decide(WorkspaceRoleOwner)
// A workspace created before authentication was enabled has no owner and
// stays visible to everyone until the setup wizard claims it. Preserving
// this keeps single-user upgrades byte-identical.
case ref.OwnerID == "":
return decide(WorkspaceRoleOwner)
case subject.UserID != "" && ref.OwnerID == subject.UserID:
return decide(WorkspaceRoleOwner)
default:
return decide(highestRole(ref.InheritedRole, ref.MemberRole))
}
}
// highestRole returns the strongest of the roles it is given. It is the whole
// of the combining rule.
func highestRole(roles ...WorkspaceRole) WorkspaceRole {
best := WorkspaceRoleNone
for _, role := range roles {
if workspaceRoleRank(role) > workspaceRoleRank(best) {
best = role
}
}
return best
}
// Denied is the fail-closed decision. Resolution errors return this rather
// than falling back to the org default role, which is the branch that would
// silently widen access under a transient database failure.
func Denied() Decision { return decide(WorkspaceRoleNone) }
func decide(role WorkspaceRole) Decision {
return Decision{Role: role, Scopes: WorkspaceScopes(role)}
}
// SubjectOrgScopes returns the org scopes a subject holds. An unscoped caller
// holds every org scope.
func SubjectOrgScopes(subject Subject) Set {
if subject.Unscoped {
return orgScopeSet()
}
return OrgScopes(subject.OrgRole)
}
func orgScopeSet() Set {
out := NewSet()
for _, def := range registry {
if def.Kind == KindOrg {
out[def.Scope] = struct{}{}
}
}
return out
}
// crossOrg reports whether the subject and the workspace belong to different
// tenants. Both sides must carry an org for the check to apply, so a
// single-org instance (organizations off) is unaffected.
func crossOrg(subject Subject, ref WorkspaceRef) bool {
if subject.Unscoped {
return false
}
// With tenancy on, a caller carrying no org reaches nothing. Treating them
// as org-less would make an unmigrated account a key to every tenant,
// because an empty org matches everything.
if subject.TenancyEnforced && subject.OrgID == "" {
return true
}
if subject.OrgID == "" || ref.OrgID == "" {
return false
}
return subject.OrgID != ref.OrgID
}