1fb82f09b2
---ci--- project: orca phase: 6 milestone: v0.12 status: execute ---/ci--- Add KindOidc to ACL: OIDCClaims struct, OidcIdentity, OidcGroupIdentity, CheckOidc (checks user sub + group: prefix entries). KindToken now always denies (R-021: no Orca-issued tokens). Existing acl.json entries with KindToken are inert (P07 removes, P22 migrates). acl.json file mode tightened to 0600. Deny-by-default enforced. 4 new OIDC ACL tests + deprecation test. Existing tests migrated to KindOidc. All pass.
223 lines
7.0 KiB
Go
223 lines
7.0 KiB
Go
// 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/<ns>/sa/<sa>/<alloc> (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://<trust-domain>/ns/<ns>/sa/<sa>/<alloc-id>. 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/<ns>/sa/<sa>/<alloc-id> 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
|
|
}
|