Files
orca/internal/daemon/acl.go
T
Jon Chery 5232fcb808 fix(P04): wire ACL enforcement + WebAuthn reg auth + audit actor (REQ-153)
R-023: Zero-trust enforcement operationally wired.

ACL enforcement (C-45 staged rollout):
- acl.Check wired into all 5 daemon handlers (dispatch/jobs/nodes/tasks)
- health endpoints exempt (liveness probes not gated)
- ACL log-only mode default (config acl.enforce=false); enforce after
  bootstrap ACL verified
- sshpush auth: ORCA_OIDC_TOKEN validated against JWKS before apply
- txn apply: Authorize hook validates OIDC token before running pull
- acl.json mode 0600 (was 0644)
- flock on acl.json for concurrent grant/revoke
- bootstrap ACL: init grants cluster-admin to orca-admins group + SVID

Audit actor identity:
- currentActor reads OIDC sub from credentials.json (was hardcoded "cli")
- threaded through all audit.Record calls via context

WebAuthn registration auth:
- BeginRegistration/FinishRegistration require authenticated session
- fail-closed 401 when no authFunc configured

New files: internal/daemon/acl.go, internal/cli/authactor.go,
internal/engine/actor.go, internal/identity/authtoken.go,
internal/sshpush/auth.go, internal/txn/auth_test.go

---ci---
project: orca
phase: 4
milestone: v0.13
status: complete
requirements:
  covered: [153]
---/ci---
2026-08-07 20:33:39 +00:00

258 lines
8.6 KiB
Go

// Package daemon — acl.go provides the access-control enforcement
// layer wired into the daemon's HTTP handlers (P04, v0.13; C-44/C-45).
//
// The daemon extracts the caller's identity from the mTLS peer
// certificate (SPIFFE SVID URI SAN, or OIDC sub in the cert's
// Subject.CommonName when the IdP embeds it), loads the cluster ACL
// from paths.ACLPath(), and calls acl.Check before dispatching the
// request. Health endpoints (/healthz, /readyz, /v1/status) are
// exempt (liveness probes must not be gated on authorization).
//
// C-45 staged rollout: when the daemon is configured with
// enforce=false (the default for the first run after wiring), ACL
// denials are LOGGED but NOT enforced — the request proceeds. This
// lets operators verify the bootstrap ACL grants the right identities
// before flipping to enforce mode. The operator switches via the
// `acl.enforce` config flag.
package daemon
import (
"crypto/x509"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
// aclPolicy is the runtime ACL enforcement policy for the daemon.
// It is constructed once at server start (see NewACLPolicy) and
// shared across handlers. The zero value is deny-by-default with
// enforce=true.
type aclPolicy struct {
// enforcer is the loaded ACL. nil means "no ACL file present" —
// in that case deny-by-default applies (no identity has any
// permission).
enforcer *acl.ACL
// enforce controls whether denials return 403 (true) or are
// logged but allowed (false, C-45 log-only mode). The default
// for the first run after P04 wiring is false.
enforce bool
log *slog.Logger
}
// NewACLPolicy loads the ACL from paths.ACLPath() and returns a
// policy. A missing ACL file is treated as an empty ACL (deny-by-
// default). enforce controls C-45 staged rollout.
func NewACLPolicy(enforce bool, log *slog.Logger) *aclPolicy {
if log == nil {
log = slog.Default()
}
p := &aclPolicy{enforce: enforce, log: log, enforcer: acl.NewACL()}
a, err := loadDaemonACL()
if err != nil {
log.Warn("acl load failed; deny-by-default with empty ACL",
slog.String("component", "daemon"),
slog.String("error", err.Error()))
return p
}
if a != nil {
p.enforcer = a
}
log.Info("acl policy loaded",
slog.String("component", "daemon"),
slog.Bool("enforce", enforce),
slog.Int("entries", len(p.enforcer.List())))
return p
}
// aclState mirrors internal/cli/aclState (kept private there). We
// duplicate the JSON shape to avoid an import cycle (cli imports
// daemon transitively via the binary, but daemon must not import cli).
type aclState struct {
Entries []acl.ACLEntry `json:"entries"`
}
// loadDaemonACL reads paths.ACLPath() and returns an *acl.ACL. A
// missing file is treated as an empty ACL (not an error).
func loadDaemonACL() (*acl.ACL, error) {
a := acl.NewACL()
path := paths.ACLPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return a, nil
}
return nil, fmt.Errorf("read acl state: %w", err)
}
if len(data) == 0 {
return a, nil
}
var st aclState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parse acl state: %w", err)
}
for _, e := range st.Entries {
a.Grant(e.Identity, e.Namespace, e.Permissions)
}
return a, nil
}
// IdentityFromCert extracts the caller's identity from an mTLS peer
// certificate. It prefers a SPIFFE SVID URI SAN (KindSpiffe); if no
// spiffe:// URI is present, it falls back to the cert's
// Subject.CommonName as an OIDC sub (KindOidc). Returns an error if
// the cert carries neither (unauthenticated).
//
// The namespace for a SPIFFE identity is extracted from the URI path;
// for an OIDC identity the namespace is empty (the ACL check takes
// the namespace as a separate argument).
func IdentityFromCert(cert *x509.Certificate) (acl.Identity, error) {
if cert == nil {
return acl.Identity{}, fmt.Errorf("acl: peer certificate is nil")
}
for _, u := range cert.URIs {
if u == nil {
continue
}
s := u.String()
if strings.HasPrefix(s, "spiffe://") {
ns, err := acl.SpiffeNamespace(s)
if err != nil {
// Malformed spiffe URI — treat as unauthenticated so
// the deny-by-default path applies. Log the error at
// the call site.
return acl.Identity{Kind: acl.KindSpiffe, ID: s, Namespace: ""}, fmt.Errorf("acl: malformed spiffe uri: %w", err)
}
return acl.Identity{Kind: acl.KindSpiffe, ID: s, Namespace: ns}, nil
}
}
if cn := cert.Subject.CommonName; cn != "" {
return acl.Identity{Kind: acl.KindOidc, ID: cn}, nil
}
return acl.Identity{}, fmt.Errorf("acl: peer cert has no spiffe URI SAN and no CommonName (unauthenticated)")
}
// peerIdentity extracts the identity from the request's mTLS peer
// certificate. Returns an error (and the zero Identity) if no peer
// cert is present or the cert carries no identity. The caller is
// expected to deny the request in that case.
func peerIdentity(r *http.Request) (acl.Identity, error) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
return acl.Identity{}, fmt.Errorf("acl: no mTLS peer certificate (unauthenticated)")
}
return IdentityFromCert(r.TLS.PeerCertificates[0])
}
// Check evaluates whether the caller identified by the request's mTLS
// peer cert has perm on ns. It returns the extracted identity (for
// audit logging) and a boolean allow.
//
// In enforce=true mode, a denial returns allow=false and the handler
// is expected to write a 403. In enforce=false mode (C-45 log-only),
// a denial is logged but allow=true is returned so the request
// proceeds — this lets operators verify the bootstrap ACL before
// flipping to enforce.
//
// A request with no peer cert (unauthenticated) is denied in enforce
// mode and allowed (but logged) in log-only mode, so health probes
// and bootstrap traffic keep flowing during rollout. Operators should
// flip to enforce=true as soon as the bootstrap ACL is verified.
func (p *aclPolicy) Check(r *http.Request, ns string, perm acl.Permission) (identity acl.Identity, allow bool) {
id, err := peerIdentity(r)
if err != nil {
// Unauthenticated. In enforce mode: deny. In log-only mode:
// log + allow (C-45: keep traffic flowing during rollout).
p.log.Warn("acl denial (unauthenticated)",
slog.String("component", "daemon"),
slog.String("namespace", ns),
slog.String("permission", permName(perm)),
slog.String("error", err.Error()),
slog.Bool("enforce", p.enforce),
)
if p.enforce {
return acl.Identity{}, false
}
return acl.Identity{}, true
}
allowed := p.enforcer.Check(id, ns, perm)
if !allowed {
p.log.Warn("acl denial",
slog.String("component", "daemon"),
slog.String("identity_kind", id.Kind),
slog.String("identity_id", id.ID),
slog.String("namespace", ns),
slog.String("permission", permName(perm)),
slog.Bool("enforce", p.enforce),
)
if p.enforce {
return id, false
}
return id, true
}
return id, true
}
// CheckOidc evaluates an OIDC-claims identity (sub + groups) against
// the ACL. Used by paths that have a verified ID token (e.g. the
// SSH-push applier validates ORCA_OIDC_TOKEN and threads the claims
// here). Returns allow=true in log-only mode even on denial.
func (p *aclPolicy) CheckOidc(claims acl.OIDCClaims, ns string, perm acl.Permission) (allow bool) {
allowed := p.enforcer.CheckOidc(claims, ns, perm)
if !allowed {
p.log.Warn("acl denial (oidc)",
slog.String("component", "daemon"),
slog.String("oidc_sub", claims.Subject),
slog.String("namespace", ns),
slog.String("permission", permName(perm)),
slog.Bool("enforce", p.enforce),
)
if p.enforce {
return false
}
return true
}
return true
}
// Enforce reports whether the policy is in enforce mode (C-45).
func (p *aclPolicy) Enforce() bool { return p.enforce }
// permName renders a Permission bitmask as a comma-separated string
// for log lines. Mirrors internal/cli.permName but is duplicated here
// to avoid an import cycle.
func permName(p acl.Permission) string {
var parts []string
if p&acl.PermRead != 0 {
parts = append(parts, "read")
}
if p&acl.PermWrite != 0 {
parts = append(parts, "write")
}
if p&acl.PermAdmin != 0 {
parts = append(parts, "admin")
}
if len(parts) == 0 {
return "none"
}
return strings.Join(parts, ",")
}
// deny writes a 403 with the standard error envelope.
func deny(w http.ResponseWriter, id acl.Identity, ns string, perm acl.Permission) {
msg := fmt.Sprintf("access denied: %s %s on %s", permName(perm), idDisplay(id), ns)
writeError(w, http.StatusForbidden, msg)
}
// idDisplay renders an identity for error/log messages.
func idDisplay(id acl.Identity) string {
if id.ID == "" {
return "anonymous"
}
return id.Kind + ":" + id.ID
}