// 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 }