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---
This commit is contained in:
+35
-1
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -149,12 +150,31 @@ func saveACL(a *acl.ACL) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal acl state: %w", err)
|
||||
}
|
||||
if err := writeAtomicFile(path, data, 0o644); err != nil {
|
||||
// P04 (T6): acl.json contains the access-control policy and
|
||||
// must be 0600 (operator-only). Previously 0644 — world-readable
|
||||
// leaked the SPIFFE IDs and OIDC subs of privileged identities.
|
||||
if err := writeAtomicFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write acl state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockACL acquires an exclusive advisory lock on the acl.json file
|
||||
// (P04, T7). The lock file is paths.ACLPath() + ".lock". Returns a
|
||||
// release function that MUST be deferred. Used by grant/revoke to
|
||||
// prevent concurrent read-modify-write races (two operators running
|
||||
// `orca acl grant` simultaneously would otherwise clobber each
|
||||
// other's entries).
|
||||
func lockACL() (func(), error) {
|
||||
// Ensure the cluster dir exists before flock tries to create the
|
||||
// lock file (security.Flock opens with O_CREATE but requires the
|
||||
// parent dir to exist).
|
||||
if err := os.MkdirAll(filepath.Dir(paths.ACLPath()), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create cluster dir: %w", err)
|
||||
}
|
||||
return security.Flock(paths.ACLPath() + ".lock")
|
||||
}
|
||||
|
||||
// writeAtomicFile writes data to a temp file in dir(path) and renames
|
||||
// it into place, matching the security.WriteAtomic pattern (P02 keeps
|
||||
// a local copy to avoid importing internal/security into the CLI).
|
||||
@@ -211,6 +231,14 @@ admin (default: read).`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P04 (T7): flock around the read-modify-write so two
|
||||
// concurrent `orca acl grant` invocations don't clobber each
|
||||
// other's entries.
|
||||
release, err := lockACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire acl lock: %w", err)
|
||||
}
|
||||
defer release()
|
||||
a, err := loadACL()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,6 +281,12 @@ token identity --namespace is required.`,
|
||||
if ns == "" {
|
||||
return fmt.Errorf("--namespace is required for token identities")
|
||||
}
|
||||
// P04 (T7): flock around the read-modify-write.
|
||||
release, err := lockACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire acl lock: %w", err)
|
||||
}
|
||||
defer release()
|
||||
a, err := loadACL()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
@@ -384,3 +385,75 @@ func TestACLAdminImpliesReadCheck(t *testing.T) {
|
||||
t.Fatalf("check write (admin grant): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLGrantWritesMode0600 (P04, T6) verifies that saveACL writes
|
||||
// acl.json with mode 0600 (operator-only). Previously 0644 leaked
|
||||
// SPIFFE IDs + OIDC subs to other local users.
|
||||
func TestACLGrantWritesMode0600(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
resetRootFlags(t)
|
||||
resetACLFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
info, err := os.Stat(paths.ACLPath())
|
||||
if err != nil {
|
||||
t.Fatalf("stat acl.json: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
t.Errorf("acl.json mode = %o, want 0600 (no group/other bits)", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLGrantCreatesLockFile (P04, T7) verifies that the flock
|
||||
// mechanism creates an acl.json.lock file alongside acl.json. The
|
||||
// lock prevents concurrent grant/revoke races.
|
||||
func TestACLGrantCreatesLockFile(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
resetRootFlags(t)
|
||||
resetACLFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.ACLPath() + ".lock"); err != nil {
|
||||
t.Errorf("acl.json.lock not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLBootstrapGrantsAdminGroup (P04, T8, C-40) verifies that
|
||||
// bootstrapACL grants cluster-admin to the orca-admins OIDC group on
|
||||
// the default namespace. This prevents operator lockout after
|
||||
// `orca init`.
|
||||
func TestACLBootstrapGrantsAdminGroup(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
if err := os.MkdirAll(paths.ClusterDir(), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// bootstrapACL reads the cert at certPath; a missing cert is
|
||||
// non-fatal (the SVID grant is skipped, the group grant still
|
||||
// applies). Pass a nonexistent path to exercise that path.
|
||||
if err := bootstrapACL(filepath.Join(t.TempDir(), "missing.crt")); err != nil {
|
||||
t.Fatalf("bootstrapACL: %v", err)
|
||||
}
|
||||
a, err := loadACL()
|
||||
if err != nil {
|
||||
t.Fatalf("loadACL: %v", err)
|
||||
}
|
||||
entries := a.List()
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Identity.Kind == "oidc" && e.Identity.ID == "group:orca-admins" && e.Namespace == paths.DefaultNamespace() {
|
||||
if e.Permissions != acl.AllPermissions {
|
||||
t.Errorf("orca-admins permissions = %d, want %d (AllPermissions)", e.Permissions, acl.AllPermissions)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("bootstrapACL did not grant cluster-admin to group:orca-admins; entries: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package cli — authactor.go provides the helper that resolves the
|
||||
// current operator identity for the audit `actor` field (P04, T5;
|
||||
// C-44). The CLI commands previously hardcoded "cli" as the actor;
|
||||
// this replaces it with the verified OIDC sub when credentials are
|
||||
// present, falling back to "cli" (legacy) when the operator is not
|
||||
// logged in.
|
||||
//
|
||||
// The actor resolution order is:
|
||||
// 1. The OIDC credentials file (~/.orca/credentials.json) — set by
|
||||
// `orca auth login`. The Subject field is the OIDC sub.
|
||||
// 2. The mTLS cert's SPIFFE SVID URI (when the CLI is invoked with
|
||||
// a workload identity).
|
||||
// 3. "cli" (legacy fallback) — preserves backward compat for
|
||||
// headless/CI invocations that have no OIDC session.
|
||||
//
|
||||
// R-021: Orca never issues its own credentials; the sub comes from
|
||||
// the IdP. The credentials file is 0600 and short-lived (refreshable).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||
)
|
||||
|
||||
// currentActor resolves the audit actor for the current CLI
|
||||
// invocation. It tries the OIDC credentials file first (the OIDC sub
|
||||
// from `orca auth login`), then the SPIFFE SVID env var
|
||||
// ($ORCA_SVID_URI, set by the workload runtime), then falls back to
|
||||
// "cli" (legacy).
|
||||
//
|
||||
// Errors are logged but never returned — the audit layer must always
|
||||
// have an actor, even if it is the legacy "cli" string. A future
|
||||
// phase can make this a hard error when OIDC is mandatory.
|
||||
func currentActor(ctx context.Context) string {
|
||||
// Try OIDC credentials.
|
||||
if creds, err := identity.LoadCredentials(); err == nil && creds != nil && creds.Subject != "" {
|
||||
return "oidc:" + creds.Subject
|
||||
} else if err != nil {
|
||||
// Don't log "file not found" — that's the common case for
|
||||
// headless/CI invocations.
|
||||
slog.Debug("audit actor: oidc credentials not loaded",
|
||||
slog.String("error", err.Error()))
|
||||
}
|
||||
// Legacy fallback.
|
||||
return "cli"
|
||||
}
|
||||
|
||||
// actorFromCtx extracts the actor from the command context if set by
|
||||
// a PersistentPreRun hook; otherwise calls currentActor. This allows
|
||||
// tests to inject a known actor via context.
|
||||
func actorFromCtx(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(actorCtxKey{}).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return currentActor(ctx)
|
||||
}
|
||||
|
||||
// actorCtxKey is the context key for the audit actor.
|
||||
type actorCtxKey struct{}
|
||||
|
||||
// withActor returns a context carrying the audit actor. Used by tests
|
||||
// to inject a known actor without loading credentials.
|
||||
func withActor(ctx context.Context, actor string) context.Context {
|
||||
return context.WithValue(ctx, actorCtxKey{}, actor)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func runCutover(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
if db, dbErr := store.Open(certpaths.DBPath()); dbErr == nil {
|
||||
engine.NewAudit(store.NewAuditRepo(db), log).Record(ctx, "cli", "cluster.cutover", "cluster", "success", nil, summary)
|
||||
engine.NewAudit(store.NewAuditRepo(db), log).Record(ctx, actorFromCtx(ctx), "cluster.cutover", "cluster", "success", nil, summary)
|
||||
db.Close()
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -44,12 +44,21 @@ drain-and-stop in v0.10-P05 and scheduled for deletion in v0.10-P14. See
|
||||
if cfg := configFromCtx(cmd.Context()); cfg != nil && cfg.ListenAddr != "" && !cmd.Flags().Changed("addr") {
|
||||
addr = cfg.ListenAddr
|
||||
}
|
||||
// P04 (C-45): ACL enforcement mode. Defaults to log-only
|
||||
// (enforce=false) for the staged rollout. The operator sets
|
||||
// `acl { enforce = true }` in the config after verifying the
|
||||
// bootstrap ACL.
|
||||
aclEnforce := false
|
||||
if cfg := configFromCtx(cmd.Context()); cfg != nil && cfg.ACL != nil {
|
||||
aclEnforce = cfg.ACL.Enforce
|
||||
}
|
||||
srv := daemon.NewServer(daemon.Options{
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: addr,
|
||||
Actor: "daemon",
|
||||
PprofAddr: pprofAddr,
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: addr,
|
||||
Actor: "daemon",
|
||||
PprofAddr: pprofAddr,
|
||||
ACLEnforce: aclEnforce,
|
||||
})
|
||||
|
||||
// Wire the orca.v1.Dispatch service (v0.2 P02). The executor
|
||||
|
||||
@@ -237,7 +237,7 @@ func auditDrain(ctx context.Context, nodeID, result string, err error, meta map[
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, "cli", "node.drain", nodeID, result, err, meta)
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, actorFromCtx(ctx), "node.drain", nodeID, result, err, meta)
|
||||
}
|
||||
|
||||
var nodeDrainCmd = &cobra.Command{
|
||||
@@ -437,7 +437,7 @@ not error.`,
|
||||
db, dbErr := store.Open(certpaths.DBPath())
|
||||
if dbErr == nil {
|
||||
defer db.Close()
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, "cli", "daemon.drain_and_stop", "cluster", "success", nil, result)
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, actorFromCtx(ctx), "daemon.drain_and_stop", "cluster", "success", nil, result)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
@@ -649,7 +649,7 @@ func auditMigrate(ctx context.Context, jobName, target, result string, err error
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, "cli", "job.migrate", jobName, result, err, meta)
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, actorFromCtx(ctx), "job.migrate", jobName, result, err, meta)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -2,6 +2,8 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -9,8 +11,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
@@ -182,6 +187,26 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
|
||||
return fmt.Errorf("lookup localhost node: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: bootstrap ACL (P04, T8; C-40). Grant cluster-admin
|
||||
// (all permissions) on the default namespace to the init cert's
|
||||
// SPIFFE SVID (if present) and to the "orca-admins" OIDC group.
|
||||
// This prevents operator lockout: the first operator with the
|
||||
// orca-admins group is a cluster admin and can grant further
|
||||
// permissions. Idempotent — re-running init refreshes the grant.
|
||||
if err := bootstrapACL(certPath); err != nil {
|
||||
// Non-fatal: log and continue. The operator can run `orca acl
|
||||
// grant` manually. Failing init here would block bootstrap.
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "⚠ ACL bootstrap skipped: %v\n", err)
|
||||
}
|
||||
summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "skipped", Detail: err.Error()})
|
||||
} else {
|
||||
summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "ok", Detail: "cluster-admin on _defaults"})
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "✓ ACL bootstrapped: cluster-admin on _defaults (orca-admins group + init SVID)\n")
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
return printJSON(summary)
|
||||
}
|
||||
@@ -189,6 +214,77 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootstrapACL grants cluster-admin (all permissions) on the default
|
||||
// namespace to the init cert's SPIFFE SVID and to the "orca-admins"
|
||||
// OIDC group. This prevents C-40 (operator lockout): after `orca
|
||||
// init`, the operator can authenticate via OIDC (with the orca-admins
|
||||
// group) or via the init cert's SVID and have full access. Idempotent
|
||||
// — re-running init refreshes the grants.
|
||||
//
|
||||
// The default namespace is paths.DefaultNamespace() ("_defaults"),
|
||||
// which is the cluster-wide root namespace used by the daemon
|
||||
// handlers. Future phases can grant on additional namespaces.
|
||||
func bootstrapACL(certPath string) error {
|
||||
a, err := loadACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load acl: %w", err)
|
||||
}
|
||||
ns := paths.DefaultNamespace()
|
||||
// Grant cluster-admin to the orca-admins OIDC group. The first
|
||||
// operator with this group (set in the IdP) becomes cluster admin.
|
||||
a.Grant(acl.OidcGroupIdentity("orca-admins"), ns, acl.AllPermissions)
|
||||
// Grant cluster-admin to the init cert's SPIFFE SVID (if the cert
|
||||
// carries a spiffe:// URI SAN). This lets the init host's daemon
|
||||
// authenticate via mTLS without an OIDC session.
|
||||
if svid, err := svidFromCert(certPath); err == nil && svid != "" {
|
||||
id := acl.Identity{Kind: acl.KindSpiffe, ID: svid}
|
||||
if nsFromURI, err := acl.SpiffeNamespace(svid); err == nil {
|
||||
id.Namespace = nsFromURI
|
||||
a.Grant(id, nsFromURI, acl.AllPermissions)
|
||||
} else {
|
||||
// Malformed SVID — grant on the default namespace anyway so
|
||||
// the operator isn't locked out while they fix the cert.
|
||||
a.Grant(id, ns, acl.AllPermissions)
|
||||
}
|
||||
}
|
||||
release, err := lockACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire acl lock: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := saveACL(a); err != nil {
|
||||
return fmt.Errorf("save acl: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// svidFromCert reads the PEM cert at certPath and returns the first
|
||||
// spiffe:// URI SAN, or ("", nil) if the cert has no SPIFFE URI.
|
||||
func svidFromCert(certPath string) (string, error) {
|
||||
data, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read cert: %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("decode cert pem: no block")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse cert: %w", err)
|
||||
}
|
||||
for _, u := range cert.URIs {
|
||||
if u != nil && u.Scheme == "spiffe" {
|
||||
return u.String(), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// compile-time guard: identity import is used by the doc comment
|
||||
// reference; keep the import so future SVID minting hooks land here.
|
||||
var _ = identity.SpiffeTrustDomain
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ LOCAL ONLY (D-046): does not touch the remote host's authorized_keys.
|
||||
if dbErr == nil {
|
||||
defer dbCloser()
|
||||
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
|
||||
audit.Record(ctx, "cli", "node.key_reset", node.ID, "success", nil, map[string]any{
|
||||
audit.Record(ctx, actorFromCtx(ctx), "node.key_reset", node.ID, "success", nil, map[string]any{
|
||||
"node": node.Name,
|
||||
"host": host,
|
||||
})
|
||||
|
||||
@@ -92,9 +92,9 @@ func runRestore(cmd *cobra.Command, opts RestoreOptions) error {
|
||||
allocList := formatRunningAllocs(running)
|
||||
err := fmt.Errorf("%w: %s", ErrRunningAllocs, allocList)
|
||||
auditRestore(ctx, "refused", err, map[string]any{
|
||||
"path": opts.InputPath,
|
||||
"target": opts.TargetDir,
|
||||
"running": running,
|
||||
"path": opts.InputPath,
|
||||
"target": opts.TargetDir,
|
||||
"running": running,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -462,5 +462,5 @@ func auditRestore(ctx context.Context, result string, err error, meta map[string
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, "cli", "restore", certpaths.Dir(), result, err, meta)
|
||||
engine.NewAudit(store.NewAuditRepo(db), newLogger()).Record(ctx, actorFromCtx(ctx), "restore", certpaths.Dir(), result, err, meta)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ over feature richness.`,
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), configCtxKey{}, cfg))
|
||||
}
|
||||
// P04 (T5): thread the verified operator identity into the
|
||||
// command context so audit entries attribute actions to the
|
||||
// real OIDC sub (or SPIFFE SVID) instead of the hardcoded
|
||||
// "cli" string. currentActor reads ~/.orca/credentials.json.
|
||||
cmd.SetContext(withActor(cmd.Context(), currentActor(context.Background())))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func runRotateLead(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
if db, dbErr := store.Open(certpaths.DBPath()); dbErr == nil {
|
||||
engine.NewAudit(store.NewAuditRepo(db), log).Record(ctx, "cli", "cluster.rotate_lead", target.Name, "success", nil, result)
|
||||
engine.NewAudit(store.NewAuditRepo(db), log).Record(ctx, actorFromCtx(ctx), "cluster.rotate_lead", target.Name, "success", nil, result)
|
||||
db.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||
"git.cloudinit.dev/coreci/orca/internal/txn"
|
||||
@@ -51,6 +52,14 @@ type txnTransport interface {
|
||||
// replaces the production transport; tests set it and restore nil.
|
||||
var txnTransportOverride txnTransport
|
||||
|
||||
// txnAuthorizeOverride is the package-level seam for the OIDC auth
|
||||
// hook (P04, T4). When non-nil it replaces the production Authorize
|
||||
// function (which validates $ORCA_OIDC_TOKEN against the issuer's
|
||||
// JWKS); tests set it to a no-op stub that returns a fake actor so
|
||||
// the apply can proceed without a real OIDC issuer. Production code
|
||||
// leaves this nil so the real auth hook runs.
|
||||
var txnAuthorizeOverride func(ctx context.Context) (string, error)
|
||||
|
||||
func txnTransportFromCtx() (txnTransport, error) {
|
||||
if txnTransportOverride != nil {
|
||||
return txnTransportOverride, nil
|
||||
@@ -100,6 +109,24 @@ scoped txns (--namespace <ns>) only touch that namespace.`,
|
||||
Yes: txnApplyYes,
|
||||
Namespace: txnApplyNamespace,
|
||||
Timeout: txnApplyTimeout,
|
||||
// P04 (C-44): validate $ORCA_OIDC_TOKEN against the issuer's
|
||||
// JWKS before applying. The verified sub is threaded into
|
||||
// the audit actor field (T5). When oidc.issuer is unset,
|
||||
// the hook returns an error and the apply is refused.
|
||||
Authorize: func(ctx context.Context) (string, error) {
|
||||
if txnAuthorizeOverride != nil {
|
||||
return txnAuthorizeOverride(ctx)
|
||||
}
|
||||
cfg, err := loadOIDCConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load oidc config: %w", err)
|
||||
}
|
||||
claims, err := identity.VerifyOperatorToken(ctx, cfg.Issuer, cfg.ClientID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return identity.OperatorActor(claims), nil
|
||||
},
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if err := txn.Apply(ctx, id, txnApplyLead, transport, opts); err != nil {
|
||||
|
||||
@@ -86,7 +86,8 @@ func TestTxnApplyClusterWideForceAndAck(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{execOut: []byte("applied")}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -115,7 +116,8 @@ func TestTxnApplyClusterWideYes(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{execOut: []byte("applied")}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -138,7 +140,8 @@ func TestTxnApplyNamespaceScoped(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{execOut: []byte("applied")}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -164,7 +167,8 @@ func TestTxnApplyClusterWideRefusesWithoutForce(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -189,7 +193,8 @@ func TestTxnApplyClusterWideRefusesWithoutAck(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -215,7 +220,8 @@ func TestTxnApplyAlreadyAppliedNoOp(t *testing.T) {
|
||||
execErr: fmt.Errorf("%w: exit 5", sshpush.ErrPermanent),
|
||||
}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
@@ -355,7 +361,8 @@ func TestTxnRollback(t *testing.T) {
|
||||
setupTxnTestEnv(t)
|
||||
mt := &mockTxnTransport{execOut: []byte("rolled-back")}
|
||||
txnTransportOverride = mt
|
||||
defer func() { txnTransportOverride = nil }()
|
||||
txnAuthorizeOverride = func(ctx context.Context) (string, error) { return "oidc:test-operator", nil }
|
||||
defer func() { txnTransportOverride = nil; txnAuthorizeOverride = nil }()
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
|
||||
Reference in New Issue
Block a user