// Package acl implements the orca access-control layer. // // An Identity is one of: // - KindSpiffe: a verified SPIFFE workload SVID whose URI is // spiffe://orca.local/ns//sa// (machine identity). // - KindOidc: a verified OIDC ID token whose subject (sub) + groups // map to namespace permissions (human identity, R-021). // // KindToken is DEPRECATED and always denies (R-021: no Orca-issued // tokens). Existing acl.json entries with KindToken are inert; P07 // removes them and P22 migrates them. // // Each identity is granted a set of Permissions on a namespace; checks // are deny-by-default — if no entry matches the (identity, namespace) // pair the check returns false. package acl import ( "fmt" "net/url" "strings" "sync" ) const ( KindSpiffe = "spiffe" KindToken = "token" // DEPRECATED: always denies (R-021). Removed by P07. KindOidc = "oidc" ) // Permission is a bitmask of access rights on a namespace. type Permission uint8 // Permission flags. Admin implies Read and Write. const ( PermRead Permission = 1 PermWrite Permission = 2 PermAdmin Permission = 4 ) // AllPermissions is the union of Read + Write + Admin. const AllPermissions Permission = PermRead | PermWrite | PermAdmin // Identity is a principal recognized by the ACL layer. Kind is one of // KindSpiffe / KindToken. ID is the SPIFFE URI (for spiffe identities) // or the token ID (for token identities). Namespace is the namespace // scope — for a SPIFFE identity it is extracted from the URI path; // for a token it is the namespace claim set at creation time. type Identity struct { Kind string `json:"kind"` ID string `json:"id"` Namespace string `json:"namespace"` } // ACLEntry binds an Identity to a Namespace with a Permission set. // A single identity may have at most one entry per namespace; granting // again on the same namespace replaces the permissions. type ACLEntry struct { Identity Identity `json:"identity"` Namespace string `json:"namespace"` Permissions Permission `json:"permissions"` } // ACL is a thread-safe list of ACLEntry. Deny-by-default: an identity // with no matching entry has no permissions. type ACL struct { mu sync.RWMutex entries []ACLEntry } // NewACL returns an empty ACL. func NewACL() *ACL { return &ACL{entries: make([]ACLEntry, 0)} } // Grant adds or replaces the entry for (identity, ns). If an entry // already exists for the same identity (matching Kind+ID) on the same // namespace, its Permissions are overwritten. func (a *ACL) Grant(identity Identity, ns string, perms Permission) { a.mu.Lock() defer a.mu.Unlock() for i, e := range a.entries { if e.Identity.Kind == identity.Kind && e.Identity.ID == identity.ID && e.Namespace == ns { a.entries[i].Permissions = perms return } } a.entries = append(a.entries, ACLEntry{ Identity: identity, Namespace: ns, Permissions: perms, }) } // Revoke removes the entry for (identity, ns) if present. Revoking a // non-existent entry is a no-op. func (a *ACL) Revoke(identity Identity, ns string) { a.mu.Lock() defer a.mu.Unlock() for i, e := range a.entries { if e.Identity.Kind == identity.Kind && e.Identity.ID == identity.ID && e.Namespace == ns { a.entries = append(a.entries[:i], a.entries[i+1:]...) return } } } // Check reports whether identity has perm on ns. Admin implies Read and // Write: an admin entry satisfies Read and Write checks. Returns false // (deny-by-default) if no entry matches. KindToken always denies // (R-021: no Orca-issued tokens); existing acl.json entries with // KindToken are inert. func (a *ACL) Check(identity Identity, ns string, perm Permission) bool { if identity.Kind == KindToken { return false } a.mu.RLock() defer a.mu.RUnlock() for _, e := range a.entries { if e.Identity.Kind == KindToken { continue } if e.Identity.Kind != identity.Kind || e.Identity.ID != identity.ID || e.Namespace != ns { continue } if e.Permissions&perm != 0 { return true } if e.Permissions&PermAdmin != 0 && (perm == PermRead || perm == PermWrite) { return true } return false } return false } // List returns a copy of all entries. The slice is safe to mutate. func (a *ACL) List() []ACLEntry { a.mu.RLock() defer a.mu.RUnlock() out := make([]ACLEntry, len(a.entries)) copy(out, a.entries) return out } // SpiffeNamespace extracts the namespace from a SPIFFE URI of the // form spiffe:///ns//sa//. It accepts // any trust domain (the caller is expected to have verified the SVID // against the expected trust domain via identity.VerifySVID). Returns // an error if the URI is not a valid spiffe:// URI or the path does // not match the ns//sa// shape. func SpiffeNamespace(uri string) (string, error) { u, err := url.Parse(uri) if err != nil { return "", fmt.Errorf("acl: parse spiffe uri: %w", err) } if u.Scheme != "spiffe" { return "", fmt.Errorf("acl: not a spiffe uri: %q", uri) } parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") if len(parts) != 5 || parts[0] != "ns" || parts[2] != "sa" { return "", fmt.Errorf("acl: malformed spiffe path %q", u.Path) } if parts[1] == "" { return "", fmt.Errorf("acl: empty namespace in spiffe path %q", u.Path) } return parts[1], nil } // OIDCClaims holds the verified claims from an OIDC ID token used by // the ACL layer. The Subject (sub) is the stable user identifier; // Groups are the group memberships used to match group-based grants. type OIDCClaims struct { Subject string Groups []string } // OidcIdentity builds an Identity from verified OIDC claims. The ID // is the OIDC subject (sub). The Namespace is empty (OIDC identities // are not namespace-scoped at the identity layer; the ACL check takes // the namespace as a separate argument). func OidcIdentity(claims OIDCClaims) Identity { return Identity{ Kind: KindOidc, ID: claims.Subject, } } // OidcGroupIdentity builds an Identity for a group-based grant. The // ID is the group name prefixed with "group:". This allows ACL // entries to grant permissions to a group (e.g. "orca-admins") and // any OIDC user with that group inherits the permission. func OidcGroupIdentity(group string) Identity { return Identity{ Kind: KindOidc, ID: "group:" + group, } } // CheckOidc reports whether an OIDC user (by sub + groups) has perm // on ns. It checks both the user's own entry (by sub) and any group // entries (by group: prefix). Admin implies Read + Write. func (a *ACL) CheckOidc(claims OIDCClaims, ns string, perm Permission) bool { // First check the user's own entry. if a.Check(OidcIdentity(claims), ns, perm) { return true } // Then check each group entry. for _, g := range claims.Groups { if a.Check(OidcGroupIdentity(g), ns, perm) { return true } } return false } // CheckTokenDeprecated is a stub that always returns false. KindToken // is deprecated (R-021); this ensures any existing KindToken entries in // acl.json are inert. P07 removes them; P22 migrates. func (a *ACL) CheckTokenDeprecated(tokenID, ns string, perm Permission) bool { return false }