5232fcb808
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---
258 lines
8.6 KiB
Go
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
|
|
}
|