Files
orca/internal/acl/acl.go
T
Jon Chery 33c2b4a78b feat(P02): ACL — SPIFFE + token identities, deny-by-default
internal/acl/acl.go: Identity, Permission, ACLEntry, ACL with
Grant/Revoke/Check/List; SpiffeNamespace extraction; deny-by-default.
internal/cli/acl.go: orca acl grant/revoke/list/check CLI;
state at cluster/acl.json. Tests: grant/revoke/deny/ns-isolation/concurrent.

---ci---
project: orca
phase: 02
milestone: v0.11
status: execute
---/ci---
2026-08-07 04:38:00 +00:00

153 lines
4.7 KiB
Go

// Package acl implements the orca access-control layer (P02, v0.11).
//
// An Identity is either a SPIFFE workload identity (verified SVID whose
// URI is spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc>) or an operator
// token (a bare token ID carrying an explicit namespace claim). 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"
)
// 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.
func (a *ACL) Check(identity Identity, ns string, perm Permission) bool {
a.mu.RLock()
defer a.mu.RUnlock()
for _, e := range a.entries {
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
}