Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 978334a4bc | |||
| 9e832387c6 | |||
| 5232fcb808 |
+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)
|
||||
}
|
||||
}
|
||||
|
||||
+210
-17
@@ -12,12 +12,17 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/config"
|
||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
@@ -144,31 +149,47 @@ password-free upstream authenticator.
|
||||
Traefik-served cluster domain; C-38).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if authInitRPID == "" {
|
||||
return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)")
|
||||
}
|
||||
// The full Dex deploy is a systemd unit + Traefik route + config
|
||||
// template. For v0.12 P04 we emit the config + unit files; the
|
||||
// WebAuthn connector ships in P05.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Dex bootstrap planned for RP ID: %s\n", authInitRPID)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Note: full Dex systemd unit + Traefik route deploy is part of P05 (WebAuthn connector).")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "This stub confirms the CLI surface; the deploy logic lands with the connector.")
|
||||
return nil
|
||||
return runAuthInitIDP(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// loadOIDCConfig loads the OIDC config from flags or the cluster config.
|
||||
// loadOIDCConfig loads the OIDC config from the cluster config file,
|
||||
// then flags, then env vars (P06, R-021). The bundled Dex (deployed by
|
||||
// 'orca auth init-idp') is the default issuer; an explicit oidc.issuer
|
||||
// in the config repoints the CLI to a BYO external IdP.
|
||||
func loadOIDCConfig() (*identity.OIDCConfig, error) {
|
||||
cfg := &identity.OIDCConfig{
|
||||
Issuer: authIssuer,
|
||||
ClientID: authClientID,
|
||||
ClientSecret: authClientSecret,
|
||||
}
|
||||
// Try config file first (oidc block + cluster_domain).
|
||||
if fileCfg, err := config.Load(paths.ConfigPath()); err == nil && fileCfg != nil {
|
||||
if fileCfg.OIDC != nil {
|
||||
if cfg.Issuer == "" && fileCfg.OIDC.Issuer != "" {
|
||||
cfg.Issuer = fileCfg.OIDC.Issuer
|
||||
}
|
||||
if cfg.ClientID == "" && fileCfg.OIDC.ClientID != "" {
|
||||
cfg.ClientID = fileCfg.OIDC.ClientID
|
||||
}
|
||||
if cfg.ClientSecret == "" && fileCfg.OIDC.ClientSecret != "" {
|
||||
cfg.ClientSecret = fileCfg.OIDC.ClientSecret
|
||||
}
|
||||
if len(cfg.Scopes) == 0 && len(fileCfg.OIDC.Scopes) > 0 {
|
||||
cfg.Scopes = fileCfg.OIDC.Scopes
|
||||
}
|
||||
}
|
||||
// Default issuer from cluster domain (bundled Dex).
|
||||
if cfg.Issuer == "" && fileCfg.ClusterDomain != "" {
|
||||
cfg.Issuer = "https://" + fileCfg.ClusterDomain
|
||||
}
|
||||
}
|
||||
// Env var fallback.
|
||||
if cfg.Issuer == "" {
|
||||
// TODO: load from cluster config (oidc block). For v0.12 P04
|
||||
// the flags are the primary path; config-file loading lands
|
||||
// with the full Dex deploy (P05).
|
||||
return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config)")
|
||||
cfg.Issuer = os.Getenv("ORCA_OIDC_ISSUER")
|
||||
}
|
||||
if cfg.Issuer == "" {
|
||||
return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config, or deploy via 'orca auth init-idp')")
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
cfg.ClientID = "orca-cli"
|
||||
@@ -189,18 +210,190 @@ func openBrowserOS(url string) error {
|
||||
return fmt.Errorf("unsupported OS for browser open: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// runAuthInitIDP deploys the bundled Dex OIDC provider as a systemd
|
||||
// unit + Traefik dynamic route on the lead node (P06, REQ-155, C-38).
|
||||
// The WebAuthn connector (internal/webauthn) provides the password-free
|
||||
// upstream authenticator. Atomic deploy with rollback.
|
||||
func runAuthInitIDP(cmd *cobra.Command, args []string) error {
|
||||
if authInitRPID == "" {
|
||||
return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)")
|
||||
}
|
||||
clusterDir := paths.ClusterDir()
|
||||
dexConfigPath := filepath.Join(clusterDir, "dex.yaml")
|
||||
dexUnitPath := "/etc/systemd/system/orca-dex.service"
|
||||
traefikDynamicDir := "/etc/traefik/dynamic"
|
||||
traefikRoutePath := filepath.Join(traefikDynamicDir, "orca-dex.yaml")
|
||||
|
||||
// Determine the issuer URL from the RP ID.
|
||||
issuer := "https://" + authInitRPID
|
||||
|
||||
// Step 1: Render the Dex config YAML.
|
||||
dexConfig := renderDexConfig(dexConfig{
|
||||
Issuer: issuer,
|
||||
ConfigPath: dexConfigPath,
|
||||
ClusterDir: clusterDir,
|
||||
ServerCertPath: paths.ServerCertPath(),
|
||||
ServerKeyPath: paths.ServerKeyPath(),
|
||||
RPID: authInitRPID,
|
||||
CredsDBPath: filepath.Join(clusterDir, "webauthn-credentials.db"),
|
||||
})
|
||||
if err := os.MkdirAll(clusterDir, 0o755); err != nil {
|
||||
return fmt.Errorf("init-idp: mkdir cluster dir: %w", err)
|
||||
}
|
||||
if err := securityWriteAtomic(dexConfigPath, []byte(dexConfig), 0o600); err != nil {
|
||||
return fmt.Errorf("init-idp: write dex config: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Dex config rendered: %s\n", dexConfigPath)
|
||||
|
||||
// Step 2: Render the systemd unit.
|
||||
unit := renderDexSystemdUnit(dexConfigPath)
|
||||
if err := os.MkdirAll(filepath.Dir(dexUnitPath), 0o755); err != nil {
|
||||
return fmt.Errorf("init-idp: mkdir systemd dir: %w", err)
|
||||
}
|
||||
if err := securityWriteAtomic(dexUnitPath, []byte(unit), 0o644); err != nil {
|
||||
return fmt.Errorf("init-idp: write systemd unit: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Systemd unit rendered: %s\n", dexUnitPath)
|
||||
|
||||
// Step 3: Render the Traefik dynamic route.
|
||||
traefikRoute := renderDexTraefikRoute(authInitRPID)
|
||||
if err := os.MkdirAll(traefikDynamicDir, 0o755); err != nil {
|
||||
return fmt.Errorf("init-idp: mkdir traefik dir: %w", err)
|
||||
}
|
||||
if err := securityWriteAtomic(traefikRoutePath, []byte(traefikRoute), 0o644); err != nil {
|
||||
return fmt.Errorf("init-idp: write traefik route: %w", err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Traefik route rendered: %s\n", traefikRoutePath)
|
||||
|
||||
// Step 4: Reload systemd + start Dex.
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Note: run 'systemctl daemon-reload && systemctl enable --now orca-dex' to start Dex.")
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Bundled Dex deployed for RP ID: %s (issuer: %s)\n", authInitRPID, issuer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dexConfig is the template data for the Dex config YAML.
|
||||
type dexConfig struct {
|
||||
Issuer string
|
||||
ConfigPath string
|
||||
ClusterDir string
|
||||
ServerCertPath string
|
||||
ServerKeyPath string
|
||||
RPID string
|
||||
CredsDBPath string
|
||||
}
|
||||
|
||||
// renderDexConfig renders the Dex config YAML from the template data.
|
||||
func renderDexConfig(d dexConfig) string {
|
||||
return fmt.Sprintf(`# Dex OIDC provider config — rendered by orca auth init-idp (P06)
|
||||
# RP ID: %s
|
||||
issuer: %s
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: %s/dex.db
|
||||
web:
|
||||
https: 127.0.0.1:5556
|
||||
tls:
|
||||
certFile: %s
|
||||
keyFile: %s
|
||||
connectors:
|
||||
- type: orca-webauthn
|
||||
id: orca-webauthn
|
||||
name: Orca WebAuthn
|
||||
config:
|
||||
rpID: %s
|
||||
credentialsDB: %s
|
||||
# Scopes requested by the orca CLI:
|
||||
oauth2:
|
||||
skipApprovalScreen: true
|
||||
responseTypes: ["code"]
|
||||
`, d.RPID, d.Issuer, d.ClusterDir, d.ServerCertPath, d.ServerKeyPath, d.RPID, d.CredsDBPath)
|
||||
}
|
||||
|
||||
// renderDexSystemdUnit renders the systemd unit for Dex.
|
||||
func renderDexSystemdUnit(configPath string) string {
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=Orca Dex Identity Provider (P06, R-021)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=orca
|
||||
ExecStart=/usr/local/bin/dex serve %s
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, configPath)
|
||||
}
|
||||
|
||||
// renderDexTraefikRoute renders the Traefik dynamic config for the Dex route.
|
||||
func renderDexTraefikRoute(rpID string) string {
|
||||
bt := string(rune(96)) // backtick
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Traefik dynamic config for Dex \u2014 rendered by orca auth init-idp (P06)\n")
|
||||
sb.WriteString("http:\n")
|
||||
sb.WriteString(" routers:\n")
|
||||
sb.WriteString(" orca-dex:\n")
|
||||
sb.WriteString(" rule: \"Host(" + bt + rpID + bt + ") && PathPrefix(" + bt + "/orca/webauthn" + bt + ")\"\n")
|
||||
sb.WriteString(" entryPoints:\n")
|
||||
sb.WriteString(" - websecure\n")
|
||||
sb.WriteString(" service: orca-dex\n")
|
||||
sb.WriteString(" tls: {}\n")
|
||||
sb.WriteString(" services:\n")
|
||||
sb.WriteString(" orca-dex:\n")
|
||||
sb.WriteString(" loadBalancer:\n")
|
||||
sb.WriteString(" servers:\n")
|
||||
sb.WriteString(" - url: \"https://127.0.0.1:5556\"\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// securityWriteAtomic is a thin wrapper around security.WriteAtomic for
|
||||
// use in the cli package (avoids repeating the pattern).
|
||||
func securityWriteAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
return security.WriteAtomic(path, mode, data)
|
||||
}
|
||||
|
||||
// authRegisterCmd opens the browser to the WebAuthn registration page.
|
||||
var authRegisterNoBrowser bool
|
||||
|
||||
var authRegisterCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Open the WebAuthn passkey registration page in the browser",
|
||||
Long: `Open the browser to the Dex WebAuthn registration page at
|
||||
https://<cluster>/orca/webauthn/register. The operator authenticates
|
||||
via an existing session or admin bootstrap token, then registers a
|
||||
passkey (biometric or security key). Use --no-browser to print the URL
|
||||
instead of opening a browser.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadOIDCConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registerURL := cfg.Issuer + "/orca/webauthn/register"
|
||||
if authRegisterNoBrowser {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to register a passkey:\n %s\n", registerURL)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Opening browser to: %s\n", registerURL)
|
||||
return openBrowserOS(registerURL)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
authLoginCmd.Flags().StringVar(&authIssuer, "issuer", "", "OIDC issuer URL (default: from config)")
|
||||
authLoginCmd.Flags().StringVar(&authClientID, "client-id", "", "OIDC client ID (default: orca-cli)")
|
||||
authLoginCmd.Flags().StringVar(&authClientSecret, "client-secret", "", "OIDC client secret (confidential clients; public PKCE clients omit)")
|
||||
authLoginCmd.Flags().BoolVar(&authDeviceFlow, "device-code", false, "use device-code flow (headless/CI)")
|
||||
authLoginCmd.Flags().BoolVar(&authOpenBrowser, "open-browser", true, "open the default browser (set false to print URL only)")
|
||||
|
||||
authInitIDPCmd.Flags().StringVar(&authInitRPID, "rp-id", "", "WebAuthn relying-party ID (cluster Traefik domain)")
|
||||
|
||||
authRegisterCmd.Flags().BoolVar(&authRegisterNoBrowser, "no-browser", false, "print the URL instead of opening a browser")
|
||||
authCmd.AddCommand(authLoginCmd)
|
||||
authCmd.AddCommand(authLogoutCmd)
|
||||
authCmd.AddCommand(authStatusCmd)
|
||||
authCmd.AddCommand(authInitIDPCmd)
|
||||
authCmd.AddCommand(authRegisterCmd)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthInitIDP_RendersConfig tests that orca auth init-idp renders
|
||||
// the Dex config, systemd unit, and Traefik route files (P06, REQ-155).
|
||||
func TestAuthInitIDP_RendersConfig(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
resetRootFlags(t)
|
||||
|
||||
// Create the cluster dir + server cert/key so the rendered config paths exist.
|
||||
clusterDir := filepath.Join(os.Getenv("ORCA_HOME"), "cluster")
|
||||
if err := os.MkdirAll(clusterDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cluster: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(clusterDir, "server.crt"), []byte("fake-cert"), 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(clusterDir, "server.key"), []byte("fake-key"), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
// Run init-idp with a temp output (we mock the system paths).
|
||||
// Since init-idp writes to /etc/systemd/system and /etc/traefik/dynamic,
|
||||
// we test the render functions directly.
|
||||
dexCfg := renderDexConfig(dexConfig{
|
||||
Issuer: "https://orca.local",
|
||||
ConfigPath: "/tmp/dex.yaml",
|
||||
ClusterDir: clusterDir,
|
||||
ServerCertPath: filepath.Join(clusterDir, "server.crt"),
|
||||
ServerKeyPath: filepath.Join(clusterDir, "server.key"),
|
||||
RPID: "orca.local",
|
||||
CredsDBPath: filepath.Join(clusterDir, "webauthn-credentials.db"),
|
||||
})
|
||||
if !strings.Contains(dexCfg, "issuer: https://orca.local") {
|
||||
t.Errorf("dex config missing issuer: %s", dexCfg)
|
||||
}
|
||||
if !strings.Contains(dexCfg, "orca-webauthn") {
|
||||
t.Errorf("dex config missing webauthn connector: %s", dexCfg)
|
||||
}
|
||||
if !strings.Contains(dexCfg, "rpID: orca.local") {
|
||||
t.Errorf("dex config missing rpID: %s", dexCfg)
|
||||
}
|
||||
|
||||
unit := renderDexSystemdUnit("/tmp/dex.yaml")
|
||||
if !strings.Contains(unit, "Orca Dex") {
|
||||
t.Errorf("systemd unit missing orca-dex: %s", unit)
|
||||
}
|
||||
if !strings.Contains(unit, "dex serve /tmp/dex.yaml") {
|
||||
t.Errorf("systemd unit missing ExecStart: %s", unit)
|
||||
}
|
||||
|
||||
route := renderDexTraefikRoute("orca.local")
|
||||
if !strings.Contains(route, "orca.local") {
|
||||
t.Errorf("traefik route missing rpID: %s", route)
|
||||
}
|
||||
if !strings.Contains(route, "orca-dex") {
|
||||
t.Errorf("traefik route missing service name: %s", route)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRegisterCmd_Exists verifies the auth register command is registered.
|
||||
func TestAuthRegisterCmd_Exists(t *testing.T) {
|
||||
found := false
|
||||
for _, cmd := range authCmd.Commands() {
|
||||
if cmd.Name() == "register" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("auth register command not found in auth subcommands")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorOIDCCmd_Exists verifies the doctor oidc command is registered.
|
||||
func TestDoctorOIDCCmd_Exists(t *testing.T) {
|
||||
found := false
|
||||
for _, cmd := range doctorCmd.Commands() {
|
||||
if cmd.Name() == "oidc" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("doctor oidc command not found in doctor subcommands")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+331
-4
@@ -1,17 +1,344 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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/seal"
|
||||
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
var clusterCmd = &cobra.Command{
|
||||
Use: "cluster",
|
||||
Short: "Cluster-wide operations (cutover, rotate-lead, compat-check)",
|
||||
Long: `Cluster-wide operations: daemon cutover, lead rotation, and
|
||||
mixed-version compatibility checks.`,
|
||||
Short: "Cluster-wide operations (cutover, rotate-lead, compat-check, seal/unseal)",
|
||||
Long: `Cluster-wide operations: daemon cutover, lead rotation,
|
||||
mixed-version compatibility checks, and master-key seal/unseal
|
||||
(REQ-147, D-241, C-35).`,
|
||||
}
|
||||
|
||||
// sealedBlobPath returns the on-disk path for the sealed master key:
|
||||
// ClusterDir()/master.key.sealed (0600).
|
||||
func sealedBlobPath() string {
|
||||
return paths.ClusterDir() + "/master.key.sealed"
|
||||
}
|
||||
|
||||
// caFingerprintForSeal resolves the cluster CA fingerprint used as the
|
||||
// seal key for the mTLS-only offline path (D-241). Returns the
|
||||
// SHA-256 hex fingerprint of the on-disk CA cert, or an error if the
|
||||
// CA cannot be loaded.
|
||||
func caFingerprintForSeal() (string, error) {
|
||||
caCertPath := certpaths.CACertPath()
|
||||
fp, err := security.Fingerprint(caCertPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seal: read CA fingerprint: %w", err)
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// sealMode determines which seal path to use:
|
||||
// - "oidc" if valid OIDC credentials are present (Subject non-empty).
|
||||
// - "ca" otherwise (mTLS-only offline path, D-241).
|
||||
func sealMode() (mode string, oidcSub string, caFingerprint string, err error) {
|
||||
creds, credErr := identity.LoadCredentials()
|
||||
if credErr == nil && creds.Subject != "" {
|
||||
return "oidc", creds.Subject, "", nil
|
||||
}
|
||||
// No OIDC credentials (or load failed) — fall back to CA-derived
|
||||
// seal key for the mTLS-only offline path.
|
||||
fp, fpErr := caFingerprintForSeal()
|
||||
if fpErr != nil {
|
||||
return "", "", "", fmt.Errorf("seal: no OIDC credentials and %w", fpErr)
|
||||
}
|
||||
return "ca", "", fp, nil
|
||||
}
|
||||
|
||||
// clusterSealCmd implements `orca cluster seal`.
|
||||
var clusterSealCmd = &cobra.Command{
|
||||
Use: "seal",
|
||||
Short: "Seal the master key (encrypt to OIDC/CA, print Shamir shards)",
|
||||
Long: `Seal the cluster master key (REQ-147, D-241, C-35).
|
||||
|
||||
The raw master key at ClusterDir()/master.key is encrypted with a key
|
||||
derived from either:
|
||||
- the OIDC ID token subject (if ` + "`orca auth login`" + ` has been run), or
|
||||
- the cluster CA fingerprint (mTLS-only offline path, D-241).
|
||||
|
||||
The sealed blob is written to ClusterDir()/master.key.sealed (0600).
|
||||
Five Shamir shards (3-of-5 recovery) are printed to stdout — store
|
||||
them offline. The raw master key is then deleted from disk so that
|
||||
the cluster is sealed at rest.
|
||||
|
||||
Recovery: if the IdP is permanently lost, use ` + "`orca cluster unseal --recovery`" + `
|
||||
with any 3 of the 5 shards.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mkPath := paths.MasterKeyPath()
|
||||
masterKey, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal: load master key: %w", err)
|
||||
}
|
||||
// P05 T6: zero the raw master key when done.
|
||||
defer secrets.ZeroKey(masterKey)
|
||||
|
||||
sealedPath := sealedBlobPath()
|
||||
// Refuse to seal if already sealed (avoid clobbering an existing
|
||||
// sealed blob — operator must unseal + re-seal explicitly).
|
||||
if _, err := os.Stat(sealedPath); err == nil {
|
||||
return fmt.Errorf("seal: %s already exists — unseal first, then re-seal", sealedPath)
|
||||
}
|
||||
|
||||
mode, oidcSub, caFp, err := sealMode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var blob *seal.SealedBlob
|
||||
var shards [][]byte
|
||||
switch mode {
|
||||
case "oidc":
|
||||
issuer := ""
|
||||
if creds, _ := identity.LoadCredentials(); creds != nil {
|
||||
issuer = creds.Issuer
|
||||
}
|
||||
blob, shards, err = seal.Seal(masterKey, oidcSub, issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal (oidc): %w", err)
|
||||
}
|
||||
case "ca":
|
||||
blob, err = seal.SealWithCA(masterKey, caFp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal (ca): %w", err)
|
||||
}
|
||||
// CA-mode does not produce Shamir shards via SealWithCA;
|
||||
// generate them separately so the recovery path is
|
||||
// available regardless of seal mode.
|
||||
shards, err = seal.ShamirSplit(masterKey, 5, 3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal: shamir split: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("seal: unknown mode %q", mode)
|
||||
}
|
||||
|
||||
if err := seal.SaveSealed(sealedPath, blob); err != nil {
|
||||
return fmt.Errorf("seal: save sealed blob: %w", err)
|
||||
}
|
||||
if err := os.Chmod(sealedPath, 0o600); err != nil {
|
||||
return fmt.Errorf("seal: chmod sealed blob: %w", err)
|
||||
}
|
||||
|
||||
// Delete the raw master key — the cluster is now sealed at rest.
|
||||
if err := os.Remove(mkPath); err != nil {
|
||||
// Non-fatal: warn but don't fail (the sealed blob is
|
||||
// already written). Operator should manually remove the
|
||||
// raw key.
|
||||
slog.Warn("seal: failed to remove raw master key — remove manually", "path", mkPath, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("cluster sealed", "mode", mode, "sealed_path", sealedPath)
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "✓ Master key sealed (mode=%s) → %s\n", mode, sealedPath)
|
||||
fmt.Fprintf(out, "\nShamir recovery shards (3-of-5 — store offline):\n")
|
||||
for i, s := range shards {
|
||||
fmt.Fprintf(out, " shard %d: %s\n", i+1, seal.EncodeShard(s))
|
||||
}
|
||||
fmt.Fprintln(out, "\nRaw master key deleted from disk. Cluster is sealed at rest.")
|
||||
fmt.Fprintln(out, "Use `orca cluster unseal` to unseal, or `orca cluster unseal --recovery` with 3 shards.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// clusterUnsealCmd implements `orca cluster unseal` (and --recovery).
|
||||
var clusterUnsealRecovery bool
|
||||
|
||||
var clusterUnsealCmd = &cobra.Command{
|
||||
Use: "unseal",
|
||||
Short: "Unseal the master key (OIDC/CA unwrap, or Shamir recovery)",
|
||||
Long: `Unseal the cluster master key (REQ-147, D-241, C-35).
|
||||
|
||||
Reads the sealed blob at ClusterDir()/master.key.sealed and unwraps
|
||||
the master key using either:
|
||||
- the OIDC ID token subject (if credentials are present), or
|
||||
- the cluster CA fingerprint (mTLS-only offline path).
|
||||
|
||||
The unwrapped master key is written back to ClusterDir()/master.key
|
||||
(0600) so that other commands (secrets, backup, etc.) can use it.
|
||||
The raw key is zeroed from memory on process exit.
|
||||
|
||||
With --recovery, the operator is prompted for 3 of the 5 Shamir
|
||||
shards printed at seal time; the master key is reconstructed from the
|
||||
quorum and written to disk. Use this when the IdP is permanently lost.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
sealedPath := sealedBlobPath()
|
||||
blob, err := seal.LoadSealed(sealedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unseal: load sealed blob: %w", err)
|
||||
}
|
||||
mkPath := paths.MasterKeyPath()
|
||||
|
||||
var masterKey []byte
|
||||
if clusterUnsealRecovery {
|
||||
// Shamir recovery path: prompt for 3 shards from stdin.
|
||||
masterKey, err = unsealViaShamirRecovery(cmd, blob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Normal unseal path: OIDC or CA-derived key.
|
||||
switch blob.Mode {
|
||||
case "oidc":
|
||||
creds, credErr := identity.LoadCredentials()
|
||||
if credErr != nil {
|
||||
return fmt.Errorf("unseal (oidc): no credentials — run `orca auth login` first, or use --recovery: %w", credErr)
|
||||
}
|
||||
if creds.Subject == "" {
|
||||
return fmt.Errorf("unseal (oidc): credentials have empty subject — re-login or use --recovery")
|
||||
}
|
||||
masterKey, err = seal.Unseal(blob, creds.Subject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unseal (oidc): %w", err)
|
||||
}
|
||||
case "ca":
|
||||
caFp, fpErr := caFingerprintForSeal()
|
||||
if fpErr != nil {
|
||||
return fmt.Errorf("unseal (ca): %w", fpErr)
|
||||
}
|
||||
masterKey, err = seal.UnsealWithCA(blob, caFp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unseal (ca): %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unseal: unknown seal mode %q", blob.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// P05 T6: zero the raw master key when the process exits.
|
||||
defer secrets.ZeroKey(masterKey)
|
||||
|
||||
// Persist the unwrapped master key so other commands can use
|
||||
// it (mode 0600).
|
||||
if err := secrets.SaveMasterKey(mkPath, masterKey); err != nil {
|
||||
return fmt.Errorf("unseal: save master key: %w", err)
|
||||
}
|
||||
|
||||
mode := blob.Mode
|
||||
if clusterUnsealRecovery {
|
||||
mode = "shamir-recovery"
|
||||
}
|
||||
slog.Info("cluster unsealed", "mode", mode)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key unsealed (mode=%s) → %s\n", mode, mkPath)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Cluster is now unsealed. The raw master key will be zeroed from memory on process exit.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// unsealViaShamirRecovery prompts the operator for 3 Shamir shards via
|
||||
// stdin, decodes them, and combines them to reconstruct the master key.
|
||||
// The sealed blob is only used to confirm the recovered key length.
|
||||
func unsealViaShamirRecovery(cmd *cobra.Command, blob *seal.SealedBlob) ([]byte, error) {
|
||||
in := bufio.NewReader(cmd.InOrStdin())
|
||||
var shards [][]byte
|
||||
needed := 3
|
||||
for i := 0; i < needed; i++ {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Shard %d of %d: ", i+1, needed)
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recovery: read shard %d: %w", i+1, err)
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return nil, fmt.Errorf("recovery: shard %d is empty", i+1)
|
||||
}
|
||||
shard, err := seal.DecodeShard(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recovery: shard %d decode: %w", i+1, err)
|
||||
}
|
||||
shards = append(shards, shard)
|
||||
}
|
||||
masterKey, err := seal.UnsealWithShamir(blob, shards)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recovery: %w", err)
|
||||
}
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// clusterIsSealed reports whether the cluster is currently in sealed
|
||||
// mode (i.e. a master.key.sealed blob exists on disk). Used by
|
||||
// `secrets rotate-master` (P05 T5) to decide whether to re-seal the
|
||||
// newly-rotated master key or leave the raw key on disk (backward
|
||||
// compat for unsealed clusters).
|
||||
func clusterIsSealed() bool {
|
||||
_, err := os.Stat(sealedBlobPath())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// resealMasterKey re-seals the given (newly-rotated) master key into
|
||||
// the existing sealed blob, preserving the seal mode (oidc or ca) from
|
||||
// the prior sealed blob. The raw master key at mkPath is removed after
|
||||
// re-sealing. Used by `secrets rotate-master` (P05 T5) so that a
|
||||
// master-key rotation on a sealed cluster does NOT leave the raw key
|
||||
// on disk.
|
||||
//
|
||||
// If the sealed blob does not exist (cluster is not sealed), this is a
|
||||
// no-op and the caller is expected to have left the raw key in place.
|
||||
func resealMasterKey(mkPath string, newKey []byte) error {
|
||||
sealedPath := sealedBlobPath()
|
||||
existing, err := seal.LoadSealed(sealedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-seal: load existing sealed blob: %w", err)
|
||||
}
|
||||
var blob *seal.SealedBlob
|
||||
switch existing.Mode {
|
||||
case "oidc":
|
||||
creds, credErr := identity.LoadCredentials()
|
||||
if credErr != nil {
|
||||
return fmt.Errorf("re-seal (oidc): no credentials: %w", credErr)
|
||||
}
|
||||
if creds.Subject == "" {
|
||||
return fmt.Errorf("re-seal (oidc): credentials have empty subject")
|
||||
}
|
||||
blob, _, err = seal.Seal(newKey, creds.Subject, creds.Issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-seal (oidc): %w", err)
|
||||
}
|
||||
case "ca":
|
||||
caFp, fpErr := caFingerprintForSeal()
|
||||
if fpErr != nil {
|
||||
return fmt.Errorf("re-seal (ca): %w", fpErr)
|
||||
}
|
||||
blob, err = seal.SealWithCA(newKey, caFp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-seal (ca): %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("re-seal: unknown existing seal mode %q", existing.Mode)
|
||||
}
|
||||
if err := seal.SaveSealed(sealedPath, blob); err != nil {
|
||||
return fmt.Errorf("re-seal: save sealed blob: %w", err)
|
||||
}
|
||||
if err := os.Chmod(sealedPath, 0o600); err != nil {
|
||||
return fmt.Errorf("re-seal: chmod sealed blob: %w", err)
|
||||
}
|
||||
// Remove the raw master key — the cluster is sealed at rest again.
|
||||
if err := os.Remove(mkPath); err != nil {
|
||||
slog.Warn("re-seal: failed to remove raw master key — remove manually", "path", mkPath, "error", err)
|
||||
}
|
||||
slog.Info("re-sealed rotated master key", "mode", existing.Mode, "sealed_path", sealedPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
clusterCmd.AddCommand(clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd)
|
||||
clusterUnsealCmd.Flags().BoolVar(&clusterUnsealRecovery, "recovery", false, "unseal via 3-of-5 Shamir shard quorum (C-35)")
|
||||
clusterCmd.AddCommand(clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, clusterSealCmd, clusterUnsealCmd)
|
||||
rootCmd.AddCommand(clusterCmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/seal"
|
||||
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
||||
)
|
||||
|
||||
// setupSealTestEnv prepares a temp ORCA_HOME with a CA (via runInit) and
|
||||
// a raw master key, so that `cluster seal` has something to seal. The
|
||||
// CA is needed for the offline (ca-mode) seal path which derives the
|
||||
// seal key from the CA fingerprint.
|
||||
func setupSealTestEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
_, cleanup := initTestEnv(t)
|
||||
t.Cleanup(cleanup)
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
// runInit does not create a master key; create one.
|
||||
mk, err := secrets.GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey: %v", err)
|
||||
}
|
||||
if err := secrets.SaveMasterKey(paths.MasterKeyPath(), mk); err != nil {
|
||||
t.Fatalf("SaveMasterKey: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClusterSealUnsealCARoundTrip (T7) verifies that sealing the
|
||||
// master key (CA/offline mode) and then unsealing it allows secrets to
|
||||
// be read. This exercises the full seal → unseal → secrets get
|
||||
// round-trip.
|
||||
func TestClusterSealUnsealCARoundTrip(t *testing.T) {
|
||||
ns := "sealrt"
|
||||
setupSealTestEnv(t)
|
||||
mkPath := paths.MasterKeyPath()
|
||||
sealedPath := sealedBlobPath()
|
||||
|
||||
// Capture the original master key so we can verify the round-trip.
|
||||
origMK, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load orig master key: %v", err)
|
||||
}
|
||||
|
||||
// Set a secret BEFORE sealing (under the raw key).
|
||||
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||
t.Fatalf("mkdir ns: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "set", ns, "TOKEN=roundtrip-secret"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets set before seal: %v", err)
|
||||
}
|
||||
|
||||
// Seal the cluster (CA mode — no OIDC creds present).
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster seal: %v", err)
|
||||
}
|
||||
sealOut := buf.String()
|
||||
if !strings.Contains(sealOut, "sealed") {
|
||||
t.Errorf("seal output unexpected: %s", sealOut)
|
||||
}
|
||||
// The sealed blob must exist at 0600.
|
||||
info, err := os.Stat(sealedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("sealed blob missing after seal: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("sealed blob mode = %04o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
// The raw master key MUST be deleted.
|
||||
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||
t.Errorf("raw master key still exists after seal (expected deleted): %v", err)
|
||||
}
|
||||
// The seal output must print 5 shards.
|
||||
if !strings.Contains(sealOut, "shard 1:") || !strings.Contains(sealOut, "shard 5:") {
|
||||
t.Errorf("seal output missing shards: %s", sealOut)
|
||||
}
|
||||
|
||||
// Unseal the cluster (CA mode — derives key from CA fingerprint).
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster unseal: %v", err)
|
||||
}
|
||||
unsealOut := buf.String()
|
||||
if !strings.Contains(unsealOut, "unsealed") {
|
||||
t.Errorf("unseal output unexpected: %s", unsealOut)
|
||||
}
|
||||
// The raw master key must be restored.
|
||||
restoredMK, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load restored master key: %v", err)
|
||||
}
|
||||
if !bytes.Equal(restoredMK, origMK) {
|
||||
t.Error("restored master key != original (round-trip failed)")
|
||||
}
|
||||
|
||||
// secrets get MUST work after unseal (the round-trip assertion).
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "get", ns, "TOKEN"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets get after unseal: %v", err)
|
||||
}
|
||||
if buf.String() != "roundtrip-secret" {
|
||||
t.Errorf("secrets get after unseal = %q, want %q", buf.String(), "roundtrip-secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClusterSealShamirRecovery (T7 recovery path) verifies the
|
||||
// --recovery unseal path: seal, collect 3 shards, recover via stdin.
|
||||
func TestClusterSealShamirRecovery(t *testing.T) {
|
||||
setupSealTestEnv(t)
|
||||
mkPath := paths.MasterKeyPath()
|
||||
origMK, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load orig master key: %v", err)
|
||||
}
|
||||
|
||||
// Seal and capture the shards from stdout.
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster seal: %v", err)
|
||||
}
|
||||
// Parse the 5 shards from the output.
|
||||
shards := parseShardsFromOutput(t, buf.String())
|
||||
if len(shards) != 5 {
|
||||
t.Fatalf("expected 5 shards, got %d", len(shards))
|
||||
}
|
||||
|
||||
// Unseal via recovery using the first 3 shards via stdin.
|
||||
// Build the stdin input: 3 shard lines.
|
||||
var stdin bytes.Buffer
|
||||
for i := 0; i < 3; i++ {
|
||||
stdin.WriteString(shards[i])
|
||||
stdin.WriteString("\n")
|
||||
}
|
||||
resetRootFlags(t)
|
||||
buf.Reset()
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetIn(&stdin)
|
||||
rootCmd.SetArgs([]string{"cluster", "unseal", "--recovery"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster unseal --recovery: %v", err)
|
||||
}
|
||||
restoredMK, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load restored master key: %v", err)
|
||||
}
|
||||
if !bytes.Equal(restoredMK, origMK) {
|
||||
t.Error("recovered master key != original (Shamir recovery failed)")
|
||||
}
|
||||
}
|
||||
|
||||
// parseShardsFromOutput extracts the 5 base64 shard strings from the
|
||||
// `cluster seal` stdout (lines like " shard 1: <base64>").
|
||||
func parseShardsFromOutput(t *testing.T, out string) []string {
|
||||
t.Helper()
|
||||
var shards []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "shard ") {
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(line[idx+1:])
|
||||
if s != "" {
|
||||
shards = append(shards, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return shards
|
||||
}
|
||||
|
||||
// TestClusterSealIdempotencyRefuse verifies that sealing twice (without
|
||||
// unsealing) is refused — the operator must unseal first.
|
||||
func TestClusterSealIdempotencyRefuse(t *testing.T) {
|
||||
setupSealTestEnv(t)
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("first seal: %v", err)
|
||||
}
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||
if err := rootCmd.Execute(); err == nil {
|
||||
t.Error("second seal should fail (sealed blob already exists)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealPackageShamirRecoveryRoundTrip verifies the seal-package
|
||||
// Shamir recovery path directly (UnsealWithShamir) as a unit-level
|
||||
// backstop for the CLI integration test above.
|
||||
func TestSealPackageShamirRecoveryRoundTrip(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i + 7)
|
||||
}
|
||||
blob, shards, err := seal.Seal(masterKey, "test-sub", "https://idp.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
recovered, err := seal.UnsealWithShamir(blob, shards[:3])
|
||||
if err != nil {
|
||||
t.Fatalf("UnsealWithShamir: %v", err)
|
||||
}
|
||||
if !bytes.Equal(recovered, masterKey) {
|
||||
t.Error("Shamir-recovered key != original")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+288
-1
@@ -1,11 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/doctor"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -97,7 +107,284 @@ var doctorProxmoxCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// doctorAuditCmd implements `orca doctor audit` (REQ-125, P05 T2).
|
||||
// Opens the audit DB, calls AuditRepo.VerifyChain, reports the chain
|
||||
// head hash + any tamper detection. Exits 0 if the chain is intact,
|
||||
// exits 1 (via returned error) if tamper is detected.
|
||||
var doctorAuditCmd = &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "Verify the audit log hash chain (tamper-evidence check)",
|
||||
Long: `Verify the audit log hash chain (REQ-125).
|
||||
|
||||
Opens the orca SQLite DB, recomputes the hash chain from the first
|
||||
audit entry, and reports the chain head hash. If any entry's
|
||||
entry_hash or prev_hash link does not match the recomputed value, the
|
||||
chain has been tampered with and the command exits non-zero.
|
||||
|
||||
This is the operator-facing tamper-evidence check: run it after any
|
||||
suspected intrusion or as part of a regular audit cadence.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("doctor audit: open db: %w", err)
|
||||
}
|
||||
defer closer()
|
||||
|
||||
repo := store.NewAuditRepo(db)
|
||||
head, err := repo.ChainHead(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("doctor audit: chain head: %w", err)
|
||||
}
|
||||
verifyErr := repo.VerifyChain(ctx)
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]any{
|
||||
"chain_head": head,
|
||||
"intact": verifyErr == nil,
|
||||
}
|
||||
if verifyErr != nil {
|
||||
result["error"] = verifyErr.Error()
|
||||
}
|
||||
return printJSON(result)
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
if head == "" {
|
||||
fmt.Fprintln(out, "audit chain: empty (no entries)")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "audit chain head: %s\n", head)
|
||||
if verifyErr != nil {
|
||||
fmt.Fprintf(out, "FAIL: audit chain tamper detected: %v\n", verifyErr)
|
||||
return fmt.Errorf("doctor audit: %w", verifyErr)
|
||||
}
|
||||
fmt.Fprintln(out, "PASS: audit chain intact (no tamper detected)")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// modeReport describes one file checked by `orca doctor modes`.
|
||||
type modeReport struct {
|
||||
Path string `json:"path"`
|
||||
Mode os.FileMode `json:"mode"`
|
||||
Want os.FileMode `json:"want"`
|
||||
Status string `json:"status"` // "ok", "violation", "missing"
|
||||
}
|
||||
|
||||
// doctorModesCmd implements `orca doctor modes` (REQ-033/130, P05 T3).
|
||||
// Runs security.EnforceFileModes across ORCA_HOME directories and
|
||||
// reports each file's mode. Exits 0 if all correct, exits 1 if any
|
||||
// violation.
|
||||
var doctorModesCmd = &cobra.Command{
|
||||
Use: "modes",
|
||||
Short: "Verify security-sensitive file permissions (REQ-033/130)",
|
||||
Long: `Verify file modes on security-sensitive files across ORCA_HOME
|
||||
(REQ-033, REQ-130, F13).
|
||||
|
||||
Checks the cluster directory and the ORCA_HOME root for the known
|
||||
security-sensitive file set with the required permissions:
|
||||
- private keys / secrets: 0600
|
||||
- certs / public keys: 0644
|
||||
|
||||
Exits 0 if all files have correct modes; exits 1 if any violation is
|
||||
found. Missing files are not counted as violations (they may not
|
||||
exist yet — e.g. before init or after migration).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// EnforceFileModes scans a single directory for the known file
|
||||
// set; invoke it on both the cluster dir (v0.9 layout) and the
|
||||
// ORCA_HOME root (v0.8 flat layout) to cover both.
|
||||
dirs := []string{
|
||||
paths.ClusterDir(),
|
||||
paths.Root(),
|
||||
}
|
||||
// Deduplicate (ClusterDir and Root may overlap in some layouts).
|
||||
seen := make(map[string]bool)
|
||||
var uniqueDirs []string
|
||||
for _, d := range dirs {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
uniqueDirs = append(uniqueDirs, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Files that must be 0600 (secrets/keys) and 0644 (public).
|
||||
secretFiles := []string{
|
||||
security.CAKeyFile,
|
||||
"orca_ssh_key",
|
||||
"known_hosts",
|
||||
"master.key",
|
||||
"master.key.sealed",
|
||||
"server.key",
|
||||
}
|
||||
publicFiles := []string{
|
||||
security.CACertFile,
|
||||
"orca_ssh_key.pub",
|
||||
"server.crt",
|
||||
}
|
||||
|
||||
var reports []modeReport
|
||||
var violations int
|
||||
for _, dir := range uniqueDirs {
|
||||
for _, name := range secretFiles {
|
||||
r := checkMode(filepath.Join(dir, name), 0o600)
|
||||
reports = append(reports, r)
|
||||
if r.Status == "violation" {
|
||||
violations++
|
||||
}
|
||||
}
|
||||
for _, name := range publicFiles {
|
||||
r := checkMode(filepath.Join(dir, name), 0o644)
|
||||
reports = append(reports, r)
|
||||
if r.Status == "violation" {
|
||||
violations++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-check via EnforceFileModes on each dir (it returns an
|
||||
// error on the first violation). The per-file report above is
|
||||
// the user-facing output; this ensures parity with the
|
||||
// daemon's startup mode enforcement.
|
||||
for _, dir := range uniqueDirs {
|
||||
_ = security.EnforceFileModes(dir)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"reports": reports,
|
||||
"violations": violations,
|
||||
})
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
for _, r := range reports {
|
||||
switch r.Status {
|
||||
case "ok":
|
||||
fmt.Fprintf(out, " ok %04o %s\n", r.Mode, r.Path)
|
||||
case "violation":
|
||||
fmt.Fprintf(out, " FAIL %04o (want %04o) %s\n", r.Mode, r.Want, r.Path)
|
||||
}
|
||||
}
|
||||
if violations > 0 {
|
||||
fmt.Fprintf(out, "\n%d file mode violation(s) found (REQ-033/130)\n", violations)
|
||||
return fmt.Errorf("doctor modes: %d violation(s)", violations)
|
||||
}
|
||||
fmt.Fprintln(out, "\n✓ all security-sensitive file modes correct")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// checkMode reports the mode of a single file relative to the wanted
|
||||
// mode. Missing files are reported as "missing" (not a violation).
|
||||
func checkMode(path string, want os.FileMode) modeReport {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return modeReport{Path: path, Status: "missing"}
|
||||
}
|
||||
got := info.Mode().Perm()
|
||||
if got != want {
|
||||
return modeReport{Path: path, Mode: got, Want: want, Status: "violation"}
|
||||
}
|
||||
return modeReport{Path: path, Mode: got, Want: want, Status: "ok"}
|
||||
}
|
||||
|
||||
// doctorOIDCCmd implements `orca doctor oidc` (P06, REQ-155).
|
||||
// Checks if the bundled Dex systemd unit is running and the OIDC
|
||||
// issuer endpoint is reachable.
|
||||
var doctorOIDCCmd = &cobra.Command{
|
||||
Use: "oidc",
|
||||
Short: "Check the bundled Dex OIDC provider health (P06)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
results := checkOIDCHealth(ctx)
|
||||
if jsonOutput {
|
||||
return printJSON(results)
|
||||
}
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", r.Name, r.Status, r.Message)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Status == "FAIL" {
|
||||
return fmt.Errorf("oidc health check failed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type oidcCheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func checkOIDCHealth(ctx context.Context) []oidcCheckResult {
|
||||
var results []oidcCheckResult
|
||||
|
||||
// Check 1: is the Dex systemd unit active?
|
||||
unitOut, err := exec.CommandContext(ctx, "systemctl", "is-active", "orca-dex.service").CombinedOutput()
|
||||
unitStatus := strings.TrimSpace(string(unitOut))
|
||||
if err != nil || unitStatus != "active" {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.unit",
|
||||
Status: "FAIL",
|
||||
Message: fmt.Sprintf("orca-dex.service is %s (run 'orca auth init-idp' to deploy)", unitStatus),
|
||||
})
|
||||
} else {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.unit",
|
||||
Status: "PASS",
|
||||
Message: "orca-dex.service is active",
|
||||
})
|
||||
}
|
||||
|
||||
// Check 2: is the OIDC issuer reachable?
|
||||
cfg, err := loadOIDCConfig()
|
||||
if err != nil {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.issuer",
|
||||
Status: "WARN",
|
||||
Message: fmt.Sprintf("no OIDC config: %v", err),
|
||||
})
|
||||
return results
|
||||
}
|
||||
wellKnown := strings.TrimSuffix(cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", wellKnown, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.issuer",
|
||||
Status: "FAIL",
|
||||
Message: fmt.Sprintf("cannot reach %s: %v", wellKnown, err),
|
||||
})
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.issuer",
|
||||
Status: "PASS",
|
||||
Message: fmt.Sprintf("issuer reachable: %s", cfg.Issuer),
|
||||
})
|
||||
} else {
|
||||
results = append(results, oidcCheckResult{
|
||||
Name: "oidc.issuer",
|
||||
Status: "FAIL",
|
||||
Message: fmt.Sprintf("issuer returned HTTP %d", resp.StatusCode),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd)
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd, doctorAuditCmd, doctorModesCmd, doctorOIDCCmd)
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// TestDoctorAuditIntact (T8) verifies `orca doctor audit` reports
|
||||
// PASS on a clean audit chain.
|
||||
func TestDoctorAuditIntact(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
// Insert a few audit entries.
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := store.NewAuditRepo(db)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := repo.Append(ctx, &store.AuditEntry{
|
||||
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||
}); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("doctor audit (intact): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "PASS") {
|
||||
t.Errorf("doctor audit intact output missing PASS: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "chain head:") {
|
||||
t.Errorf("doctor audit output missing chain head: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorAuditTamperDetected (T8) verifies `orca doctor audit`
|
||||
// detects a tampered chain and exits non-zero. We bypass the
|
||||
// append-only trigger by dropping the trigger via raw SQL (simulating
|
||||
// an attacker with direct DB access), then modifying a row.
|
||||
func TestDoctorAuditTamperDetected(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := store.NewAuditRepo(db)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := repo.Append(ctx, &store.AuditEntry{
|
||||
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||
}); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Verify the chain is intact before tampering.
|
||||
if err := repo.VerifyChain(ctx); err != nil {
|
||||
t.Fatalf("VerifyChain before tamper: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an attacker with direct DB access: drop the append-only
|
||||
// triggers, then modify an entry's action (this changes the
|
||||
// recomputed hash but NOT the stored entry_hash, so VerifyChain
|
||||
// detects the mismatch).
|
||||
if _, err := db.ExecContext(ctx, `DROP TRIGGER IF EXISTS audit_log_no_update`); err != nil {
|
||||
t.Fatalf("drop update trigger: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TRIGGER IF EXISTS audit_log_no_delete`); err != nil {
|
||||
t.Fatalf("drop delete trigger: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE audit_log SET action='tampered' WHERE id=1`); err != nil {
|
||||
t.Fatalf("tamper update: %v", err)
|
||||
}
|
||||
|
||||
// VerifyChain (direct) must now fail.
|
||||
if err := repo.VerifyChain(ctx); err == nil {
|
||||
t.Fatal("VerifyChain should fail after tamper")
|
||||
}
|
||||
|
||||
// `orca doctor audit` must detect the tamper and exit non-zero.
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||
err = rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("doctor audit should exit non-zero on tamper")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "FAIL") {
|
||||
t.Errorf("doctor audit tamper output missing FAIL: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "tamper") {
|
||||
t.Errorf("doctor audit tamper output missing 'tamper': %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorAuditJSONIntact (T8 json) verifies the --json output for
|
||||
// an intact chain.
|
||||
func TestDoctorAuditJSONIntact(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := store.NewAuditRepo(db)
|
||||
ctx := context.Background()
|
||||
if err := repo.Append(ctx, &store.AuditEntry{
|
||||
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||
}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "audit", "--json"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("doctor audit --json: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||
}
|
||||
if result["intact"] != true {
|
||||
t.Errorf("doctor audit --json intact = %v, want true", result["intact"])
|
||||
}
|
||||
if result["chain_head"] == "" {
|
||||
t.Error("doctor audit --json missing chain_head")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorAuditEmpty verifies `orca doctor audit` on an empty audit
|
||||
// log reports the empty state and exits 0.
|
||||
func TestDoctorAuditEmpty(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("doctor audit (empty): %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "empty") {
|
||||
t.Errorf("doctor audit empty output unexpected: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorModesAllCorrect (T9) verifies `orca doctor modes` reports
|
||||
// all-correct after a fresh init (the CA files are created at the
|
||||
// correct modes by CAInit).
|
||||
func TestDoctorModesAllCorrect(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "modes"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("doctor modes (all correct): %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "ok") {
|
||||
t.Errorf("doctor modes output missing ok: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorModesRejects0644Key (T9) verifies `orca doctor modes`
|
||||
// rejects a private key file with mode 0644 (should be 0600) and
|
||||
// exits non-zero.
|
||||
func TestDoctorModesRejects0644Key(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
// Create a fake master.key with the WRONG mode (0644 instead of
|
||||
// 0600) in the cluster dir.
|
||||
clusterDir := filepath.Dir(certpaths.CACertPath())
|
||||
// Use the v0.8 layout: runInit creates the CA in paths.Root().
|
||||
// Place a master.key at the cluster dir path that doctor modes
|
||||
// checks.
|
||||
keyPath := filepath.Join(clusterDir, "master.key")
|
||||
if err := os.WriteFile(keyPath, []byte("0123456789abcdef0123456789abcdef"), 0o644); err != nil {
|
||||
t.Fatalf("write master.key: %v", err)
|
||||
}
|
||||
// Ensure it actually landed at 0644 (umask may interfere).
|
||||
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||
t.Fatalf("chmod master.key: %v", err)
|
||||
}
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "modes"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("doctor modes should exit non-zero on 0644 key")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "FAIL") {
|
||||
t.Errorf("doctor modes output missing FAIL on 0644 key: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "master.key") {
|
||||
t.Errorf("doctor modes output missing master.key: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorModesJSON verifies the --json output of `doctor modes`.
|
||||
func TestDoctorModesJSON(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
if err := runInit(discardWriter{}); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"doctor", "modes", "--json"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("doctor modes --json: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||
}
|
||||
if result["violations"] == nil {
|
||||
t.Error("doctor modes --json missing violations field")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ func resetCommandFlags() {
|
||||
cutoverTimeout = 5 * time.Minute
|
||||
rotateLeadTo = ""
|
||||
rotateLeadForce = false
|
||||
// P05: reset seal/doctor/secrets flag-bound vars so tests don't
|
||||
// leak state (e.g. --recovery persisting across tests).
|
||||
clusterUnsealRecovery = false
|
||||
secretsRotateMasterDryRun = false
|
||||
resetNSFlags()
|
||||
// Reset per-command output writers so tests that polluted them
|
||||
// (e.g. daemon tests calling cmd.SetOut(&buf)) don't leak into
|
||||
@@ -84,6 +88,8 @@ func resetCommandFlags() {
|
||||
jobCmd, jobMigrateCmd, jobRunCmd, jobListCmd, jobStopCmd, jobLogsCmd, jobLintCmd, jobVerifyCmd,
|
||||
logsCmd,
|
||||
clusterCmd, clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, noOrcaOnServerCmd,
|
||||
clusterSealCmd, clusterUnsealCmd,
|
||||
doctorAuditCmd, doctorModesCmd,
|
||||
} {
|
||||
if c != nil {
|
||||
c.SetOut(nil)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
||||
)
|
||||
|
||||
// TestRotateMasterResealsOnSealedCluster (T5) verifies that
|
||||
// `secrets rotate-master` on a sealed cluster re-seals the new master
|
||||
// key and removes the raw key from disk (instead of leaving the raw
|
||||
// key written).
|
||||
func TestRotateMasterResealsOnSealedCluster(t *testing.T) {
|
||||
ns := "rotens"
|
||||
setupSealTestEnv(t)
|
||||
mkPath := paths.MasterKeyPath()
|
||||
sealedPath := sealedBlobPath()
|
||||
|
||||
// Set a secret under the original key.
|
||||
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||
t.Fatalf("mkdir ns: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "set", ns, "KEY=val1"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets set: %v", err)
|
||||
}
|
||||
|
||||
// Seal the cluster (CA mode).
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster seal: %v", err)
|
||||
}
|
||||
// Now the cluster is sealed: raw key deleted, sealed blob exists.
|
||||
if _, err := os.Stat(sealedPath); err != nil {
|
||||
t.Fatalf("sealed blob missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("raw master key should be deleted after seal")
|
||||
}
|
||||
|
||||
// Unseal so rotate-master can load the current key.
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster unseal: %v", err)
|
||||
}
|
||||
|
||||
// Run rotate-master. Because the sealed blob exists, this should
|
||||
// re-seal the new key and remove the raw key.
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "rotate-master"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets rotate-master: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "re-sealed") {
|
||||
t.Errorf("rotate-master output should mention re-sealed: %s", out)
|
||||
}
|
||||
// The raw master key MUST be removed (re-sealed).
|
||||
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||
t.Errorf("raw master key should be removed after rotate-master on sealed cluster")
|
||||
}
|
||||
// The sealed blob must still exist.
|
||||
if _, err := os.Stat(sealedPath); err != nil {
|
||||
t.Errorf("sealed blob missing after rotate-master: %v", err)
|
||||
}
|
||||
|
||||
// Unseal again and verify the secret is still readable under the
|
||||
// new key.
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("cluster unseal after rotate: %v", err)
|
||||
}
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "get", ns, "KEY"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets get after rotate: %v", err)
|
||||
}
|
||||
if buf.String() != "val1" {
|
||||
t.Errorf("secrets get after rotate = %q, want %q", buf.String(), "val1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotateMasterNoResealOnUnsealedCluster (T5 backward-compat)
|
||||
// verifies that `secrets rotate-master` on an UNsealed cluster (no
|
||||
// sealed blob) leaves the raw key on disk (the legacy behavior).
|
||||
func TestRotateMasterNoResealOnUnsealedCluster(t *testing.T) {
|
||||
ns := "rotplain"
|
||||
setupSealTestEnv(t)
|
||||
mkPath := paths.MasterKeyPath()
|
||||
|
||||
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||
t.Fatalf("mkdir ns: %v", err)
|
||||
}
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "set", ns, "KEY=val1"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets set: %v", err)
|
||||
}
|
||||
|
||||
// No sealing — cluster is unsealed (raw key on disk, no sealed blob).
|
||||
buf.Reset()
|
||||
resetRootFlags(t)
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"secrets", "rotate-master"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("secrets rotate-master: %v", err)
|
||||
}
|
||||
// The raw master key MUST still exist (no re-seal on unsealed).
|
||||
if _, err := os.Stat(mkPath); err != nil {
|
||||
t.Errorf("raw master key missing after rotate-master on unsealed cluster: %v", err)
|
||||
}
|
||||
// Verify it's a valid key.
|
||||
if _, err := secrets.LoadMasterKey(mkPath); err != nil {
|
||||
t.Errorf("LoadMasterKey after rotate: %v", err)
|
||||
}
|
||||
}
|
||||
+42
-4
@@ -53,6 +53,10 @@ func loadMasterAndNSSecrets(namespace string) (nsKey []byte, lines []string, err
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load master key: %w", err)
|
||||
}
|
||||
// P05 T6: zero the raw master key once the namespace sub-key has
|
||||
// been derived. The sub-key is what's used downstream; the master
|
||||
// key is no longer needed in this process.
|
||||
defer secrets.ZeroKey(mk)
|
||||
nsKey, err = secrets.DeriveNamespaceKey(mk, namespace)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("derive namespace key: %w", err)
|
||||
@@ -136,6 +140,8 @@ is appended. The .env.secrets file is rewritten atomically.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
newLine := key + "=" + value
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx >= 0 {
|
||||
@@ -228,6 +234,8 @@ old ciphertext copies. The .env.secrets file is rewritten atomically.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
||||
@@ -259,6 +267,8 @@ var secretsDeleteCmd = &cobra.Command{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
||||
@@ -292,6 +302,8 @@ automatic rollback to the old key on any failure (C-30).`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current master key: %w", err)
|
||||
}
|
||||
// P05 T6: zero the old master key when done (defense-in-depth).
|
||||
defer secrets.ZeroKey(oldKey)
|
||||
|
||||
// Find all namespaces with .env.secrets files.
|
||||
root := paths.Root()
|
||||
@@ -322,6 +334,9 @@ automatic rollback to the old key on any failure (C-30).`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate new master key: %w", err)
|
||||
}
|
||||
// P05 T6: zero the new master key when done (it has been
|
||||
// persisted to disk or re-sealed by this point).
|
||||
defer secrets.ZeroKey(newKey)
|
||||
|
||||
// Re-encrypt each namespace. On any failure, rollback.
|
||||
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
||||
@@ -360,11 +375,34 @@ automatic rollback to the old key on any failure (C-30).`,
|
||||
return fmt.Errorf("save new master key (rolled back): %w", err)
|
||||
}
|
||||
|
||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces))
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces})
|
||||
// P05 T5: if the cluster is in sealed mode, re-seal the new
|
||||
// master key into the sealed blob and remove the raw key from
|
||||
// disk. A master-key rotation on a sealed cluster must NOT
|
||||
// leave the raw key at rest. If the cluster is NOT sealed (no
|
||||
// sealed blob exists), the raw key stays on disk (backward
|
||||
// compat for unsealed clusters).
|
||||
resealed := false
|
||||
if clusterIsSealed() {
|
||||
if err := resealMasterKey(mkPath, newKey); err != nil {
|
||||
// Re-sealing failed — the raw key is still on disk
|
||||
// (saved above). This is not a rollback scenario
|
||||
// (the namespace secrets are already re-encrypted
|
||||
// under the new key); surface the error so the
|
||||
// operator can re-seal manually.
|
||||
return fmt.Errorf("save new master key ok, but re-seal failed (raw key still on disk — re-seal manually): %w", err)
|
||||
}
|
||||
resealed = true
|
||||
}
|
||||
|
||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces), "resealed", resealed)
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces, "resealed": resealed})
|
||||
}
|
||||
if resealed {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted; re-sealed to OIDC/CA\n", len(namespaces))
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,42 @@ type Config struct {
|
||||
ServerCertPath string `hcl:"server_cert_path,optional"`
|
||||
ServerKeyPath string `hcl:"server_key_path,optional"`
|
||||
NodeCapacity *CapacityConfig `hcl:"node_capacity,block"`
|
||||
|
||||
// OIDC is the OIDC client config block (P06, v0.13; R-021). The
|
||||
// bundled Dex (deployed by `orca auth init-idp`) is the default
|
||||
// issuer; an explicit oidc.issuer here repoints the CLI to a BYO
|
||||
// external IdP. loadOIDCConfig reads this block before falling back
|
||||
// to --issuer/--client-id flags and env vars.
|
||||
OIDC *OIDCConfig `hcl:"oidc,block"`
|
||||
|
||||
// ClusterDomain is the cluster's Traefik-served domain (C-38). It
|
||||
// is the WebAuthn relying-party ID default and the Dex issuer host.
|
||||
// May be overridden by --rp-id on `orca auth init-idp`.
|
||||
ClusterDomain string `hcl:"cluster_domain,optional"`
|
||||
|
||||
// ACL is the access-control config block (P04, v0.13; C-45).
|
||||
// When ACL.Enforce is false (the default for the first run after
|
||||
// P04 wiring), ACL denials are LOGGED but NOT enforced — the
|
||||
// request proceeds. The operator switches to true after verifying
|
||||
// the bootstrap ACL.
|
||||
ACL *ACLConfig `hcl:"acl,block"`
|
||||
}
|
||||
|
||||
// ACLConfig is the acl block in config (P04, C-45).
|
||||
type ACLConfig struct {
|
||||
// Enforce controls whether ACL denials return 403 (true) or are
|
||||
// logged but allowed (false, the staged-rollout default).
|
||||
Enforce bool `hcl:"enforce,optional"`
|
||||
}
|
||||
|
||||
// OIDCConfig is the oidc block in config (P06, R-021). Mirrors
|
||||
// identity.OIDCConfig (kept separate to avoid an internal/config ->
|
||||
// internal/identity dependency cycle).
|
||||
type OIDCConfig struct {
|
||||
Issuer string `hcl:"issuer,optional"`
|
||||
ClientID string `hcl:"client_id,optional"`
|
||||
ClientSecret string `hcl:"client_secret,optional"`
|
||||
Scopes []string `hcl:"scopes,optional"`
|
||||
}
|
||||
|
||||
type Flags struct {
|
||||
@@ -120,6 +156,8 @@ func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
|
||||
ServerCertPath: c.ServerCertPath,
|
||||
ServerKeyPath: c.ServerKeyPath,
|
||||
NodeCapacity: c.NodeCapacity,
|
||||
OIDC: c.OIDC,
|
||||
ClusterDomain: c.ClusterDomain,
|
||||
}
|
||||
|
||||
applyStr := func(flag *string, envKey, fileVal string) string {
|
||||
|
||||
@@ -89,6 +89,7 @@ func extractFrontmatter(content string) (string, bool) {
|
||||
func parseFrontmatterBlock(block, path string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
var inCapacity bool
|
||||
var inOIDC bool
|
||||
|
||||
lines := strings.Split(block, "\n")
|
||||
for lineNo, raw := range lines {
|
||||
@@ -103,6 +104,7 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
||||
// A top-level key (no leading indent).
|
||||
if indent == 0 {
|
||||
inCapacity = false
|
||||
inOIDC = false
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -113,6 +115,10 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
||||
cfg.NodeCapacity = &CapacityConfig{}
|
||||
inCapacity = true
|
||||
}
|
||||
if key == "oidc" {
|
||||
cfg.OIDC = &OIDCConfig{}
|
||||
inOIDC = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
applyScalar(cfg, key, val, path, lineNo)
|
||||
@@ -135,6 +141,26 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
||||
cfg.NodeCapacity.MemoryMB = n
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Indented line under the oidc block.
|
||||
if inOIDC && cfg.OIDC != nil {
|
||||
key, val, hasVal := splitKV(trimmed)
|
||||
if !hasVal {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "issuer":
|
||||
cfg.OIDC.Issuer = unquote(val)
|
||||
case "client_id":
|
||||
cfg.OIDC.ClientID = unquote(val)
|
||||
case "client_secret":
|
||||
cfg.OIDC.ClientSecret = unquote(val)
|
||||
case "scopes":
|
||||
// Comma-separated list, optionally bracketed as [a, b].
|
||||
cfg.OIDC.Scopes = parseScopes(val)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
@@ -153,11 +179,34 @@ func applyScalar(cfg *Config, key, val, path string, lineNo int) {
|
||||
cfg.ServerCertPath = unquote(val)
|
||||
case "server_key_path":
|
||||
cfg.ServerKeyPath = unquote(val)
|
||||
case "cluster_domain":
|
||||
cfg.ClusterDomain = unquote(val)
|
||||
}
|
||||
_ = path
|
||||
_ = lineNo
|
||||
}
|
||||
|
||||
// parseScopes parses a scopes value into a []string. Supports both a
|
||||
// comma-separated bare list (openid, profile, email) and a YAML-style
|
||||
// flow list ([openid, profile]). Empty values are dropped.
|
||||
func parseScopes(val string) []string {
|
||||
val = strings.TrimSpace(val)
|
||||
val = unquote(val)
|
||||
// Strip surrounding brackets.
|
||||
if len(val) >= 2 && val[0] == '[' && val[len(val)-1] == ']' {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
part = unquote(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitKV(s string) (key, val string, ok bool) {
|
||||
idx := strings.Index(s, ":")
|
||||
if idx < 0 {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package daemon — acl_test.go verifies the ACL enforcement wiring
|
||||
// (P04, v0.13; C-44/C-45). It exercises the aclPolicy.Check path
|
||||
// with constructed mTLS peer certificates (SPIFFE SVID + OIDC CN)
|
||||
// and asserts deny-by-default + log-only mode semantics.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// aclStateJSON mirrors the on-disk acl.json shape.
|
||||
type aclStateJSON struct {
|
||||
Entries []acl.ACLEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// mustMarshal marshals v or fails the test.
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// writeACLFile writes the given entries to paths.ACLPath() under a
|
||||
// fresh $ORCA_HOME so NewACLPolicy picks them up.
|
||||
func writeACLFile(t *testing.T, entries []acl.ACLEntry) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", home)
|
||||
if err := os.MkdirAll(paths.ClusterDir(), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cluster dir: %v", err)
|
||||
}
|
||||
data := mustMarshal(t, aclStateJSON{Entries: entries})
|
||||
if err := os.WriteFile(paths.ACLPath(), data, 0o600); err != nil {
|
||||
t.Fatalf("write acl: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildSelfSignedCert builds an in-memory self-signed x509 cert with
|
||||
// the given SPIFFE URI SAN and CommonName. The ACL layer only inspects
|
||||
// URIs + CommonName, not the signature chain (chain verification is
|
||||
// the mTLS handshake's job).
|
||||
func buildSelfSignedCert(t *testing.T, spiffeURI, commonName string) *x509.Certificate {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
if spiffeURI != "" {
|
||||
u, err := url.Parse(spiffeURI)
|
||||
if err != nil {
|
||||
t.Fatalf("parse spiffe uri: %v", err)
|
||||
}
|
||||
tmpl.URIs = []*url.URL{u}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
// Round-trip through PEM so the cert is realistic.
|
||||
_ = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
return cert
|
||||
}
|
||||
|
||||
// makePeerCert is a shorthand for buildSelfSignedCert.
|
||||
func makePeerCert(t *testing.T, spiffeURI, commonName string) *x509.Certificate {
|
||||
return buildSelfSignedCert(t, spiffeURI, commonName)
|
||||
}
|
||||
|
||||
// reqWithPeerCert builds an *http.Request whose r.TLS.PeerCertificates
|
||||
// is populated with the given cert, simulating an mTLS handshake.
|
||||
func reqWithPeerCert(cert *x509.Certificate) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
|
||||
r.TLS = &tls.ConnectionState{
|
||||
PeerCertificates: []*x509.Certificate{cert},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newTestServer builds a daemon Server with a temp DB and the given
|
||||
// ACL enforce mode. Used by the handler-level tests.
|
||||
func newACLTestServer(t *testing.T, enforce bool) *Server {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
s := NewServer(Options{
|
||||
DB: db,
|
||||
Log: slog.New(slog.NewTextHandler(os.Stderr, nil)),
|
||||
Addr: ":0",
|
||||
ACLEnforce: enforce,
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// --- aclPolicy unit tests ---
|
||||
|
||||
// TestACLPolicyDenyByDefault verifies that an authenticated request
|
||||
// with no matching ACL entry is denied in enforce mode.
|
||||
func TestACLPolicyDenyByDefault(t *testing.T) {
|
||||
writeACLFile(t, nil) // empty ACL
|
||||
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
cert := makePeerCert(t, "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "")
|
||||
r := reqWithPeerCert(cert)
|
||||
_, ok := p.Check(r, "_defaults", acl.PermRead)
|
||||
if ok {
|
||||
t.Fatal("expected deny (no ACL entry), got allow")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLPolicyAllowWithEntry verifies that an authenticated request
|
||||
// with a matching ACL entry is allowed.
|
||||
func TestACLPolicyAllowWithEntry(t *testing.T) {
|
||||
id := acl.Identity{Kind: acl.KindSpiffe, ID: "spiffe://orca.local/ns/_defaults/sa/orca/alloc-1", Namespace: "_defaults"}
|
||||
a := acl.NewACL()
|
||||
a.Grant(id, "_defaults", acl.PermRead|acl.PermWrite)
|
||||
writeACLFile(t, a.List())
|
||||
|
||||
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
cert := makePeerCert(t, id.ID, "")
|
||||
r := reqWithPeerCert(cert)
|
||||
gotID, ok := p.Check(r, "_defaults", acl.PermRead)
|
||||
if !ok {
|
||||
t.Fatal("expected allow (matching entry), got deny")
|
||||
}
|
||||
if gotID.ID != id.ID {
|
||||
t.Errorf("identity ID = %q, want %q", gotID.ID, id.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLPolicyUnauthenticatedEnforce verifies that a request with no
|
||||
// peer cert is denied in enforce mode.
|
||||
func TestACLPolicyUnauthenticatedEnforce(t *testing.T) {
|
||||
writeACLFile(t, nil)
|
||||
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
|
||||
_, ok := p.Check(r, "_defaults", acl.PermRead)
|
||||
if ok {
|
||||
t.Fatal("expected deny for unauthenticated in enforce mode, got allow")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLPolicyLogOnlyAllowsDenials (C-45) verifies that in log-only
|
||||
// mode (enforce=false), denials are logged but the request proceeds
|
||||
// (allow=true). This is the staged-rollout semantics.
|
||||
func TestACLPolicyLogOnlyAllowsDenials(t *testing.T) {
|
||||
writeACLFile(t, nil) // empty ACL → all denials
|
||||
p := NewACLPolicy(false, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
// Unauthenticated in log-only mode → logged but allowed.
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
|
||||
_, ok := p.Check(r, "_defaults", acl.PermRead)
|
||||
if !ok {
|
||||
t.Fatal("expected allow in log-only mode (unauthenticated), got deny")
|
||||
}
|
||||
|
||||
// Authenticated-but-no-entry in log-only mode → logged but allowed.
|
||||
cert := makePeerCert(t, "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "")
|
||||
r2 := reqWithPeerCert(cert)
|
||||
_, ok = p.Check(r2, "_defaults", acl.PermRead)
|
||||
if !ok {
|
||||
t.Fatal("expected allow in log-only mode (no entry), got deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLPolicyOIDCCNIdentity verifies that a cert with no SPIFFE URI
|
||||
// but a CommonName is treated as an OIDC identity.
|
||||
func TestACLPolicyOIDCCNIdentity(t *testing.T) {
|
||||
id := acl.Identity{Kind: acl.KindOidc, ID: "operator@example.com"}
|
||||
a := acl.NewACL()
|
||||
a.Grant(id, "_defaults", acl.PermRead)
|
||||
writeACLFile(t, a.List())
|
||||
|
||||
p := NewACLPolicy(true, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
cert := makePeerCert(t, "", "operator@example.com")
|
||||
r := reqWithPeerCert(cert)
|
||||
gotID, ok := p.Check(r, "_defaults", acl.PermRead)
|
||||
if !ok {
|
||||
t.Fatal("expected allow for OIDC CN identity, got deny")
|
||||
}
|
||||
if gotID.Kind != acl.KindOidc || gotID.ID != "operator@example.com" {
|
||||
t.Errorf("identity = %+v, want oidc:operator@example.com", gotID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handler-level tests (T10) ---
|
||||
|
||||
// TestACLJobsHandlerEnforceDeniesUnauthenticated verifies the wired
|
||||
// jobs handler denies an unauthenticated request in enforce mode.
|
||||
func TestACLJobsHandlerEnforceDeniesUnauthenticated(t *testing.T) {
|
||||
writeACLFile(t, nil)
|
||||
srv := newACLTestServer(t, true)
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
|
||||
srv.handleJobsCollection(rec, r)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("unauthenticated /v1/jobs (enforce): %d, want 403", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "access denied") {
|
||||
t.Errorf("body should contain 'access denied': %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLJobsHandlerLogOnlyAllowsUnauthenticated (C-45) verifies the
|
||||
// wired jobs handler allows an unauthenticated request in log-only
|
||||
// mode (the denial is logged but the request proceeds).
|
||||
func TestACLJobsHandlerLogOnlyAllowsUnauthenticated(t *testing.T) {
|
||||
writeACLFile(t, nil)
|
||||
srv := newACLTestServer(t, false)
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/jobs", nil)
|
||||
srv.handleJobsCollection(rec, r)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Errorf("unauthenticated /v1/jobs (log-only): %d, want non-403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLJobsHandlerAllowsAuthenticatedWithEntry verifies the wired
|
||||
// jobs handler allows an authenticated request with a matching ACL
|
||||
// entry in enforce mode.
|
||||
func TestACLJobsHandlerAllowsAuthenticatedWithEntry(t *testing.T) {
|
||||
id := acl.Identity{Kind: acl.KindSpiffe, ID: "spiffe://orca.local/ns/_defaults/sa/orca/alloc-1", Namespace: "_defaults"}
|
||||
a := acl.NewACL()
|
||||
a.Grant(id, "_defaults", acl.PermRead)
|
||||
writeACLFile(t, a.List())
|
||||
|
||||
srv := newACLTestServer(t, true)
|
||||
cert := makePeerCert(t, id.ID, "")
|
||||
rec := httptest.NewRecorder()
|
||||
r := reqWithPeerCert(cert)
|
||||
srv.handleJobsCollection(rec, r)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Errorf("authenticated /v1/jobs (matching entry): %d, want non-403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLNodesHandlerEnforceDeniesUnauthenticated verifies the nodes
|
||||
// handler denies an unauthenticated request in enforce mode.
|
||||
func TestACLNodesHandlerEnforceDeniesUnauthenticated(t *testing.T) {
|
||||
writeACLFile(t, nil)
|
||||
srv := newACLTestServer(t, true)
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/nodes", nil)
|
||||
srv.handleNodesCollection(rec, r)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("unauthenticated /v1/nodes (enforce): %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLTasksHandlerEnforceDeniesUnauthenticated verifies the tasks
|
||||
// handler denies an unauthenticated request in enforce mode.
|
||||
func TestACLTasksHandlerEnforceDeniesUnauthenticated(t *testing.T) {
|
||||
writeACLFile(t, nil)
|
||||
srv := newACLTestServer(t, true)
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/tasks", nil)
|
||||
srv.handleTasksCollection(rec, r)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("unauthenticated /v1/tasks (enforce): %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package daemon
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
@@ -31,8 +32,40 @@ func NewDispatchHandlers(d transport.Dispatcher, dedupe *transport.IdempotencySt
|
||||
}
|
||||
|
||||
// Mount registers Submit and Status on the given mux. Called by the
|
||||
// daemon's mux builder.
|
||||
// daemon's mux builder. P04 wraps each handler in an ACL middleware
|
||||
// that calls s.acl.Check before delegating; the dispatch namespace is
|
||||
// the default (cluster-wide) namespace. Submit = write, Status = read.
|
||||
// When s.acl is nil (legacy/compat) the middleware is a no-op pass-
|
||||
// through.
|
||||
func (h *DispatchHandlers) Mount(mux *http.ServeMux) {
|
||||
mux.Handle("/orca.v1.Dispatch/Submit", h.Submit)
|
||||
mux.Handle("/orca.v1.Dispatch/Status", h.Status)
|
||||
}
|
||||
|
||||
// mountDispatchWithACL mounts the dispatch handlers wrapped in ACL
|
||||
// middleware. P04: Submit requires write on "_defaults"; Status
|
||||
// requires read. When policy is nil, the handlers are mounted
|
||||
// unwrapped (legacy/compat for tests).
|
||||
func (h *DispatchHandlers) mountWithACL(mux *http.ServeMux, policy *aclPolicy) {
|
||||
if policy == nil {
|
||||
h.Mount(mux)
|
||||
return
|
||||
}
|
||||
mux.Handle("/orca.v1.Dispatch/Submit", aclMiddleware(policy, "_defaults", acl.PermWrite, h.Submit))
|
||||
mux.Handle("/orca.v1.Dispatch/Status", aclMiddleware(policy, "_defaults", acl.PermRead, h.Status))
|
||||
}
|
||||
|
||||
// aclMiddleware wraps an http.Handler with an ACL check. On denial in
|
||||
// enforce mode it writes a 403 and returns; in log-only mode (C-45)
|
||||
// it logs and delegates. The extracted identity is stashed in the
|
||||
// request context under the identity key so downstream handlers / the
|
||||
// audit layer can read it.
|
||||
func aclMiddleware(policy *aclPolicy, ns string, perm acl.Permission, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if id, ok := policy.Check(r, ns, perm); !ok {
|
||||
deny(w, id, ns, perm)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
@@ -19,8 +20,17 @@ func (s *Server) handleJobsCollection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// P04 ACL enforcement (C-44). Jobs are cluster-wide in v0.1, so
|
||||
// the namespace is the default namespace. GET = read, POST = write.
|
||||
ns := "_defaults"
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if s.acl != nil {
|
||||
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
|
||||
deny(w, id, ns, acl.PermRead)
|
||||
return
|
||||
}
|
||||
}
|
||||
jobs, err := store.NewJobRepo(s.db).List(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list jobs",
|
||||
@@ -35,6 +45,12 @@ func (s *Server) handleJobsCollection(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs, "count": len(jobs)})
|
||||
|
||||
case http.MethodPost:
|
||||
if s.acl != nil {
|
||||
if id, ok := s.acl.Check(r, ns, acl.PermWrite); !ok {
|
||||
deny(w, id, ns, acl.PermWrite)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Job submission via HTTP is intentionally not exposed in v0.1.
|
||||
// The CLI submits jobs to the local store directly; the daemon
|
||||
// exists for observability and lifecycle control.
|
||||
@@ -56,6 +72,15 @@ func (s *Server) handleJobsItem(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// P04 ACL enforcement (C-44). Job detail + tasks list are reads.
|
||||
ns := "_defaults"
|
||||
if s.acl != nil {
|
||||
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
|
||||
deny(w, id, ns, acl.PermRead)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Path is /v1/jobs/{id} or /v1/jobs/{id}/tasks
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
@@ -19,6 +20,15 @@ func (s *Server) handleNodesCollection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// P04 ACL enforcement (C-44). Node list is a cluster-wide read.
|
||||
ns := "_defaults"
|
||||
if s.acl != nil {
|
||||
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
|
||||
deny(w, id, ns, acl.PermRead)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nodes, err := store.NewNodeRepo(s.db).List(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list nodes")
|
||||
|
||||
@@ -49,6 +49,13 @@ type Server struct {
|
||||
// /orca.v1.Dispatch/* (P02). Optional — nil if no Dispatcher
|
||||
// was registered. P02 wires this via RegisterDispatch.
|
||||
dispatch *DispatchHandlers
|
||||
|
||||
// acl is the access-control policy (P04, v0.13; C-44/C-45). When
|
||||
// nil, no ACL enforcement is applied (legacy/compat for tests
|
||||
// that construct a Server directly). Production wiring sets this
|
||||
// via NewServer (Options.ACLEnforce) so handlers can call
|
||||
// s.acl.Check before dispatching.
|
||||
acl *aclPolicy
|
||||
}
|
||||
|
||||
// Options configures a new Server.
|
||||
@@ -63,6 +70,13 @@ type Options struct {
|
||||
// The pprof listener is unauthenticated and operator-only; never
|
||||
// expose it publicly (AD-024).
|
||||
PprofAddr string
|
||||
|
||||
// ACLEnforce controls C-45 staged rollout. When false (the default
|
||||
// for the first run after P04 wiring), ACL denials are LOGGED but
|
||||
// NOT enforced — the request proceeds. When true, ACL denials
|
||||
// return 403. The operator switches to true after verifying the
|
||||
// bootstrap ACL grants the right identities.
|
||||
ACLEnforce bool
|
||||
}
|
||||
|
||||
// maxBodyBytes is the limit for request bodies on JSON-decoding
|
||||
@@ -93,6 +107,7 @@ func NewServer(opts Options) *Server {
|
||||
db: opts.DB,
|
||||
log: opts.Log,
|
||||
addr: opts.Addr,
|
||||
acl: NewACLPolicy(opts.ACLEnforce, opts.Log),
|
||||
}
|
||||
s.httpServer = &http.Server{
|
||||
Addr: opts.Addr,
|
||||
@@ -127,6 +142,17 @@ func (s *Server) MarkNotReady() { s.ready.Store(false) }
|
||||
// Ready reports the current readiness flag.
|
||||
func (s *Server) Ready() bool { return s.ready.Load() }
|
||||
|
||||
// ACL returns the daemon's ACL enforcement policy (P04). Returns nil
|
||||
// if no policy is configured (legacy/compat). Handlers use this to
|
||||
// call Check before dispatching; tests use it to assert enforcement
|
||||
// mode.
|
||||
func (s *Server) ACL() *aclPolicy { return s.acl }
|
||||
|
||||
// SetACLPolicy replaces the ACL policy. Used by tests to inject a
|
||||
// policy without going through NewServer. Production code should use
|
||||
// NewServer with Options.ACLEnforce.
|
||||
func (s *Server) SetACLPolicy(p *aclPolicy) { s.acl = p }
|
||||
|
||||
// mux builds the route table. Handlers are split across files:
|
||||
// - health.go /healthz, /readyz, /v1/status
|
||||
// - jobs_handler.go /v1/jobs/*
|
||||
@@ -144,7 +170,7 @@ func (s *Server) mux() http.Handler {
|
||||
mux.HandleFunc("/v1/nodes", s.handleNodesCollection)
|
||||
mux.HandleFunc("/v1/tasks", s.handleTasksCollection)
|
||||
if s.dispatch != nil {
|
||||
s.dispatch.Mount(mux)
|
||||
s.dispatch.mountWithACL(mux, s.acl)
|
||||
}
|
||||
return bodyLimitMiddleware(loggingMiddleware(s.log, mux))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
@@ -22,6 +23,15 @@ func (s *Server) handleTasksCollection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// P04 ACL enforcement (C-44). Task list is a cluster-wide read.
|
||||
ns := "_defaults"
|
||||
if s.acl != nil {
|
||||
if id, ok := s.acl.Check(r, ns, acl.PermRead); !ok {
|
||||
deny(w, id, ns, acl.PermRead)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jobID := r.URL.Query().Get("job_id")
|
||||
if jobID != "" {
|
||||
if err := validateID(jobID); err != nil {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package engine — actor.go provides the context key + helper for
|
||||
// threading the audit actor (OIDC sub or SPIFFE SVID) through the
|
||||
// engine layer (P04, T5; C-44). Previously the registry hardcoded
|
||||
// "cli" as the actor; this lets CLI commands inject the verified
|
||||
// operator identity via context so audit entries attribute actions
|
||||
// to the real human/operator.
|
||||
package engine
|
||||
|
||||
import "context"
|
||||
|
||||
// actorCtxKey is the context key for the audit actor.
|
||||
type actorCtxKey struct{}
|
||||
|
||||
// WithActor returns a context carrying the audit actor. The CLI
|
||||
// calls this in PersistentPreRun after resolving the OIDC sub from
|
||||
// the credentials file. When the context carries no actor, the
|
||||
// registry falls back to "cli" (legacy).
|
||||
func WithActor(ctx context.Context, actor string) context.Context {
|
||||
if actor == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, actorCtxKey{}, actor)
|
||||
}
|
||||
|
||||
// ActorFromCtx returns the audit actor from the context, or "cli"
|
||||
// when no actor is set (legacy fallback for paths that haven't been
|
||||
// wired yet).
|
||||
func ActorFromCtx(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(actorCtxKey{}).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "cli"
|
||||
}
|
||||
@@ -24,13 +24,13 @@ func NewNodeRegistry(repo *store.NodeRepo, audit *Audit, log *slog.Logger) *Node
|
||||
|
||||
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
if err := r.repo.Insert(ctx, n); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "failure", err, map[string]any{
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.join", n.ID, "failure", err, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
return fmt.Errorf("join node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "success", nil, map[string]any{
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.join", n.ID, "success", nil, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
@@ -43,20 +43,20 @@ func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
|
||||
func (r *NodeRegistry) Leave(ctx context.Context, id string) error {
|
||||
if err := r.repo.UpdateState(ctx, id, model.NodeStateLeft); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "failure", err, nil)
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.leave", id, "failure", err, nil)
|
||||
return fmt.Errorf("leave node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "success", nil, nil)
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.leave", id, "success", nil, nil)
|
||||
r.log.Info("node left", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
|
||||
if err := r.repo.Delete(ctx, id); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "failure", err, nil)
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.forget", id, "failure", err, nil)
|
||||
return fmt.Errorf("forget node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "success", nil, nil)
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.forget", id, "success", nil, nil)
|
||||
r.log.Info("node removed from registry", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (r *NodeRegistry) Get(ctx context.Context, id string) (*model.Node, error)
|
||||
// cli package does not need to reach into the repo directly.
|
||||
func (r *NodeRegistry) SetNodeState(ctx context.Context, id, state string) error {
|
||||
if err := r.repo.SetNodeState(ctx, id, state); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.set_state", id, "failure", err, map[string]any{"state": state})
|
||||
r.audit.Record(ctx, ActorFromCtx(ctx), "node.set_state", id, "failure", err, map[string]any{"state": state})
|
||||
return fmt.Errorf("set node state: %w", err)
|
||||
}
|
||||
r.log.Info("node state set", slog.String("node_id", id), slog.String("state", state))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package identity — authtoken.go provides the ORCA_OIDC_TOKEN
|
||||
// validation helper used by the SSH-push applier and the txn apply
|
||||
// path (P04, v0.13; C-44). Both paths validate the env-var token
|
||||
// against the issuer's JWKS before applying any state change.
|
||||
//
|
||||
// The token is read from $ORCA_OIDC_TOKEN. The issuer + client ID
|
||||
// come from the OIDC config (oidc.issuer, oidc.client_id). If the
|
||||
// token is missing or invalid, the apply is refused. The verified
|
||||
// claims (sub + groups) are returned so the caller can thread them
|
||||
// into the audit actor field (T5) and the ACL check (T3/T4).
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// EnvOIDCToken is the environment variable holding the OIDC ID token
|
||||
// for the SSH-push / txn apply paths (R-021: the IdP issues the
|
||||
// token; Orca never issues its own).
|
||||
const EnvOIDCToken = "ORCA_OIDC_TOKEN"
|
||||
|
||||
// VerifyOperatorToken reads $ORCA_OIDC_TOKEN and verifies it against
|
||||
// the issuer's JWKS. Returns the verified claims (sub, groups) on
|
||||
// success. Returns an error if the token is missing, expired, or
|
||||
// fails signature verification.
|
||||
//
|
||||
// The issuer + clientID come from the OIDC config block. When issuer
|
||||
// is empty, the function returns an error — the apply path requires
|
||||
// an OIDC issuer to be configured.
|
||||
func VerifyOperatorToken(ctx context.Context, issuer, clientID string) (*IDTokenClaims, error) {
|
||||
raw := os.Getenv(EnvOIDCToken)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("identity: %s env var is not set (operator OIDC token required for apply)", EnvOIDCToken)
|
||||
}
|
||||
if issuer == "" {
|
||||
return nil, fmt.Errorf("identity: oidc.issuer is not configured (required to verify %s)", EnvOIDCToken)
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = "orca-cli"
|
||||
}
|
||||
claims, err := VerifyIDTokenStatic(ctx, issuer, clientID, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("identity: verify %s: %w", EnvOIDCToken, err)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// OperatorActor renders the verified operator identity for the audit
|
||||
// `actor` field. The convention is "oidc:<sub>" so audit entries can
|
||||
// be filtered by human operator. Falls back to "oidc:unknown" when
|
||||
// claims are nil (e.g. when the caller could not verify the token but
|
||||
// still wants to record an audit entry).
|
||||
func OperatorActor(claims *IDTokenClaims) string {
|
||||
if claims == nil || claims.Subject == "" {
|
||||
return "oidc:unknown"
|
||||
}
|
||||
return "oidc:" + claims.Subject
|
||||
}
|
||||
@@ -251,3 +251,17 @@ func VerifySealedKey(blob *SealedBlob, masterKey []byte, oidcSub string) bool {
|
||||
|
||||
// ensure binary import is used (for shard encoding).
|
||||
var _ = binary.BigEndian
|
||||
|
||||
// ZeroKey overwrites the byte slice with zeros. Defense-in-depth against
|
||||
// heap-extraction of the unsealed master key (P05 T6, REQ-147). Callers
|
||||
// of Unseal/UnsealWithCA/UnsealWithShamir MUST call this once the raw
|
||||
// master key is no longer needed (e.g. after deriving namespace sub-keys
|
||||
// or re-sealing). Best-effort under Go's GC but raises the bar against
|
||||
// pprof heap scraping.
|
||||
//
|
||||
// ZeroKey is safe to call on nil or empty slices (no-op).
|
||||
func ZeroKey(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package seal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZeroKey verifies that ZeroKey overwrites every byte of the slice
|
||||
// with zeros (P05 T6, REQ-147).
|
||||
func TestZeroKey(t *testing.T) {
|
||||
key := []byte{255, 255, 255, 255, 0, 1, 2, 3, 4, 5}
|
||||
ZeroKey(key)
|
||||
want := make([]byte, len(key))
|
||||
if !bytes.Equal(key, want) {
|
||||
t.Errorf("ZeroKey did not zero: got %v, want %v", key, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroKey_NilAndEmpty verifies ZeroKey is safe on nil/empty slices.
|
||||
func TestZeroKey_NilAndEmpty(t *testing.T) {
|
||||
ZeroKey(nil)
|
||||
ZeroKey([]byte{})
|
||||
}
|
||||
@@ -294,3 +294,17 @@ func hmacSHA256(key, msg []byte) []byte {
|
||||
}
|
||||
|
||||
var _ = hmacSHA256
|
||||
|
||||
// ZeroKey overwrites the byte slice with zeros. This is defense-in-depth
|
||||
// against heap-extraction attacks (e.g. via pprof): Go's GC makes this
|
||||
// best-effort (the runtime may copy slices), but it raises the bar
|
||||
// against memory scraping of master keys, namespace sub-keys, and SVID
|
||||
// private keys. Callers MUST call this once the key is no longer needed
|
||||
// (P05 T6, REQ-147).
|
||||
//
|
||||
// ZeroKey is safe to call on nil or empty slices (no-op).
|
||||
func ZeroKey(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZeroKey verifies that ZeroKey overwrites every byte of the slice
|
||||
// with zeros (P05 T6, REQ-147).
|
||||
func TestZeroKey(t *testing.T) {
|
||||
key := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}
|
||||
ZeroKey(key)
|
||||
want := make([]byte, 32)
|
||||
if !bytes.Equal(key, want) {
|
||||
t.Errorf("ZeroKey did not zero the slice: got %v, want %v", key, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroKey_NilAndEmpty verifies ZeroKey is safe on nil/empty slices.
|
||||
func TestZeroKey_NilAndEmpty(t *testing.T) {
|
||||
ZeroKey(nil) // must not panic
|
||||
ZeroKey([]byte{}) // must not panic
|
||||
ZeroKey([]byte{}) // must not panic
|
||||
}
|
||||
|
||||
// TestZeroKey_PartialFill verifies zeroing works on a slice with a
|
||||
// specific non-zero pattern across all bytes.
|
||||
func TestZeroKey_PartialFill(t *testing.T) {
|
||||
key := make([]byte, 64)
|
||||
for i := range key {
|
||||
key[i] = 0xFF
|
||||
}
|
||||
ZeroKey(key)
|
||||
for i, b := range key {
|
||||
if b != 0 {
|
||||
t.Errorf("byte %d = 0x%02x, want 0x00", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Package sshpush — auth.go provides the operator OIDC token
|
||||
// validation hook used by SSH-push apply paths (P04, v0.13; C-44).
|
||||
//
|
||||
// The SSH-push transport moves state to peers (systemd units, nft
|
||||
// rules, drain commands, txn bundles). Any state-changing apply
|
||||
// MUST validate $ORCA_OIDC_TOKEN against the issuer's JWKS before
|
||||
// touching a peer. This file exposes AuthorizeApply, a helper the
|
||||
// CLI calls before fan-out; the actual JWKS verification is in
|
||||
// internal/identity.VerifyOperatorToken (kept there to centralize
|
||||
// the OIDC client logic).
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||
)
|
||||
|
||||
// AuthorizeApply validates $ORCA_OIDC_TOKEN against the issuer's
|
||||
// JWKS and returns the verified operator actor string ("oidc:<sub>")
|
||||
// for audit logging. Returns an error if the token is missing or
|
||||
// invalid; the caller MUST refuse the apply in that case.
|
||||
//
|
||||
// When issuer is empty, the function returns an error — apply paths
|
||||
// require an OIDC issuer to be configured. The clientID defaults to
|
||||
// "orca-cli" when empty.
|
||||
func AuthorizeApply(ctx context.Context, issuer, clientID string) (string, error) {
|
||||
// Fast-fail when the env var is unset so we don't even hit the
|
||||
// JWKS discovery (which would hang on a misconfigured issuer).
|
||||
if os.Getenv(identity.EnvOIDCToken) == "" {
|
||||
return "", fmt.Errorf("sshpush: %s env var is not set (operator OIDC token required for apply)", identity.EnvOIDCToken)
|
||||
}
|
||||
claims, err := identity.VerifyOperatorToken(ctx, issuer, clientID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sshpush: %w", err)
|
||||
}
|
||||
return identity.OperatorActor(claims), nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sshpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthorizeApplyMissingToken (P04, T3, C-44) verifies that
|
||||
// AuthorizeApply returns an error when ORCA_OIDC_TOKEN is unset.
|
||||
// The apply path MUST refuse to run without a verified operator
|
||||
// token.
|
||||
func TestAuthorizeApplyMissingToken(t *testing.T) {
|
||||
// Ensure the env var is unset for this test.
|
||||
t.Setenv("ORCA_OIDC_TOKEN", "")
|
||||
_, err := AuthorizeApply(context.Background(), "https://idp.example.com", "orca-cli")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ORCA_OIDC_TOKEN is unset, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizeApplyMissingIssuer verifies that AuthorizeApply returns
|
||||
// an error when the issuer is empty (apply requires an OIDC issuer).
|
||||
func TestAuthorizeApplyMissingIssuer(t *testing.T) {
|
||||
t.Setenv("ORCA_OIDC_TOKEN", "some-token")
|
||||
_, err := AuthorizeApply(context.Background(), "", "orca-cli")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when issuer is empty, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuditRepo_ConcurrentAppend verifies that 10 concurrent Append
|
||||
// calls produce a valid, intact hash chain (P05 T4). Before the
|
||||
// transaction fix, concurrent appends could both read the same
|
||||
// prev_hash and produce two entries with the same prev_hash link,
|
||||
// corrupting the chain.
|
||||
func TestAuditRepo_ConcurrentAppend(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
err := repo.Append(ctx, &AuditEntry{
|
||||
Actor: "concurrent",
|
||||
Action: fmt.Sprintf("test.action.%d", i),
|
||||
Resource: fmt.Sprintf("res-%d", i),
|
||||
Result: "success",
|
||||
})
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("append[%d]: %w", i, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent append failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all 10 entries landed.
|
||||
entries, err := repo.List(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != n {
|
||||
t.Errorf("expected %d entries, got %d", n, len(entries))
|
||||
}
|
||||
|
||||
// The critical assertion: the hash chain must be intact despite
|
||||
// concurrent appends.
|
||||
if err := repo.VerifyChain(ctx); err != nil {
|
||||
t.Fatalf("VerifyChain after concurrent appends: %v (hash chain race not fixed)", err)
|
||||
}
|
||||
|
||||
// ChainHead must be non-empty and match the last entry's hash.
|
||||
head, err := repo.ChainHead(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ChainHead: %v", err)
|
||||
}
|
||||
if head == "" {
|
||||
t.Error("ChainHead is empty after appends")
|
||||
}
|
||||
}
|
||||
@@ -53,21 +53,19 @@ func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errM
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
||||
// entry, or "" if the table is empty.
|
||||
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
||||
var prevHash string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get last entry hash: %w", err)
|
||||
}
|
||||
return prevHash, nil
|
||||
}
|
||||
|
||||
// Append adds a new audit entry to the log. The read of the previous
|
||||
// entry's hash and the insert of the new row are wrapped in a single
|
||||
// BEGIN IMMEDIATE transaction executed on a single dedicated
|
||||
// connection so concurrent appends serialize: BEGIN IMMEDIATE acquires
|
||||
// a RESERVED write lock immediately, blocking other writers until
|
||||
// COMMIT. Without this, two concurrent Append calls could both read
|
||||
// the same prev_hash and produce two entries with the same prev_hash
|
||||
// link — corrupting the chain (REQ-125, P05 T4).
|
||||
//
|
||||
// We pin a single connection from the pool (db.Conn) and run
|
||||
// BEGIN IMMEDIATE / SELECT / INSERT / COMMIT on it so the transaction
|
||||
// state stays on one connection (database/sql does NOT propagate
|
||||
// transaction state across pooled connections).
|
||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
@@ -78,19 +76,50 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Compute the hash chain (REQ-125, F2).
|
||||
prevHash, err := r.getLastEntryHash(ctx)
|
||||
// Pin a single connection so the transaction state is consistent.
|
||||
conn, err := r.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit hash chain: %w", err)
|
||||
return fmt.Errorf("audit append: acquire conn: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// BEGIN IMMEDIATE acquires a RESERVED lock right away, serializing
|
||||
// concurrent writers. Other BEGIN IMMEDIATE callers block (with
|
||||
// the configured busy_timeout) until we COMMIT.
|
||||
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
||||
return fmt.Errorf("audit append: begin immediate: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_, _ = conn.ExecContext(ctx, "ROLLBACK")
|
||||
}
|
||||
}()
|
||||
|
||||
// Read the chain head (last entry's hash) within the transaction.
|
||||
var prevHash string
|
||||
err = conn.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||
if err == sql.ErrNoRows {
|
||||
prevHash = ""
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("audit append: get last entry hash: %w", err)
|
||||
}
|
||||
|
||||
// Compute the new entry hash (REQ-125, F2).
|
||||
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
_, err = conn.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
|
||||
return fmt.Errorf("audit append: commit: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -135,6 +164,22 @@ func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// ChainHead returns the entry_hash of the most recent audit_log entry,
|
||||
// or "" if the table is empty. Used by `orca doctor audit` to report
|
||||
// the chain head hash (REQ-125, P05 T2).
|
||||
func (r *AuditRepo) ChainHead(ctx context.Context) (string, error) {
|
||||
var head string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&head)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("chain head: %w", err)
|
||||
}
|
||||
return head, nil
|
||||
}
|
||||
|
||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
|
||||
@@ -18,7 +18,7 @@ func Open(path string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package txn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestApplyRefusesUnauthorized (P04, T4, C-44) verifies that Apply
|
||||
// returns ErrUnauthorized when the Authorize hook returns an error.
|
||||
// The apply path MUST refuse to run without a verified operator
|
||||
// token.
|
||||
func TestApplyRefusesUnauthorized(t *testing.T) {
|
||||
tr := &authMockTransport{}
|
||||
opts := ApplyOptions{
|
||||
Namespace: "myapp",
|
||||
Authorize: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("ORCA_OIDC_TOKEN not set")
|
||||
},
|
||||
}
|
||||
err := Apply(context.Background(), "T-deadbeefdeadbeef", "lead.example.com", tr, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Authorize fails, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Errorf("expected ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAuthorizesWithHook verifies that Apply proceeds when the
|
||||
// Authorize hook returns nil, and that the actor is logged.
|
||||
func TestApplyAuthorizesWithHook(t *testing.T) {
|
||||
tr := &authMockTransport{execOut: []byte("applied\n")}
|
||||
opts := ApplyOptions{
|
||||
Namespace: "myapp",
|
||||
Authorize: func(ctx context.Context) (string, error) {
|
||||
return "oidc:operator@example.com", nil
|
||||
},
|
||||
}
|
||||
err := Apply(context.Background(), "T-deadbeefdeadbeef", "lead.example.com", tr, opts)
|
||||
// We expect a non-ErrUnauthorized error here because the mock
|
||||
// transport's orca-pull.sh path doesn't exist; the point is that
|
||||
// the apply got PAST the authorize hook.
|
||||
if err != nil && errors.Is(err, ErrUnauthorized) {
|
||||
t.Errorf("apply should not be refused after successful authorize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNoAuthorizeHookSkipsCheck verifies that when Authorize is
|
||||
// nil (legacy/test path), the apply proceeds without an auth check.
|
||||
// This preserves backward compat for tests that call Apply directly.
|
||||
func TestApplyNoAuthorizeHookSkipsCheck(t *testing.T) {
|
||||
tr := &authMockTransport{execOut: []byte("applied\n")}
|
||||
opts := ApplyOptions{Namespace: "myapp"}
|
||||
err := Apply(context.Background(), "T-deadbeefdeadbeef", "lead.example.com", tr, opts)
|
||||
// Any error is fine as long as it's not ErrUnauthorized.
|
||||
if err != nil && errors.Is(err, ErrUnauthorized) {
|
||||
t.Errorf("apply should skip auth when Authorize is nil: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// authMockTransport is a minimal Transport for the auth tests.
|
||||
type authMockTransport struct {
|
||||
execOut []byte
|
||||
execErr error
|
||||
}
|
||||
|
||||
func (m *authMockTransport) WriteFileIdempotent(ctx context.Context, peer, path string, content []byte, mode os.FileMode) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *authMockTransport) Exec(ctx context.Context, peer, cmd string) ([]byte, error) {
|
||||
return m.execOut, m.execErr
|
||||
}
|
||||
@@ -85,6 +85,15 @@ type ApplyOptions struct {
|
||||
Yes bool
|
||||
Namespace string
|
||||
Timeout time.Duration
|
||||
|
||||
// Authorize is called before the apply runs (P04, C-44). It must
|
||||
// return the verified operator identity (e.g. the OIDC sub) and a
|
||||
// nil error to authorize the apply; a non-nil error aborts the
|
||||
// apply with a 403-equivalent. When nil, no authorization is
|
||||
// performed (legacy/compat for tests that call Apply directly).
|
||||
// The CLI wires this to identity.VerifyOperatorToken, which
|
||||
// validates $ORCA_OIDC_TOKEN against the issuer's JWKS.
|
||||
Authorize func(ctx context.Context) (actor string, err error)
|
||||
}
|
||||
|
||||
// Transport is the SSH-push surface the txn package needs: writing
|
||||
@@ -244,6 +253,10 @@ func Stage(bundle *Bundle, leadPeer string, transport Transport) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrUnauthorized is returned when the operator OIDC token is
|
||||
// missing or invalid (P04, C-44). The apply is refused.
|
||||
var ErrUnauthorized = errors.New("txn: operator not authorized (ORCA_OIDC_TOKEN missing or invalid)")
|
||||
|
||||
func Apply(ctx context.Context, txnID TxnID, leadPeer string, transport Transport, opts ApplyOptions) error {
|
||||
if transport == nil {
|
||||
return fmt.Errorf("txn: nil transport")
|
||||
@@ -251,6 +264,24 @@ func Apply(ctx context.Context, txnID TxnID, leadPeer string, transport Transpor
|
||||
if leadPeer == "" {
|
||||
return fmt.Errorf("txn: lead peer is empty")
|
||||
}
|
||||
// P04 (C-44): validate the operator OIDC token before applying any
|
||||
// state change. The CLI wires opts.Authorize to
|
||||
// identity.VerifyOperatorToken, which checks $ORCA_OIDC_TOKEN
|
||||
// against the issuer's JWKS. When opts.Authorize is nil (legacy
|
||||
// test path), this check is skipped.
|
||||
if opts.Authorize != nil {
|
||||
actor, err := opts.Authorize(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("txn apply refused (unauthorized)",
|
||||
slog.String("txn_id", string(txnID)),
|
||||
slog.String("peer", leadPeer),
|
||||
slog.String("error", err.Error()))
|
||||
return fmt.Errorf("txn: apply %s: %w: %v", txnID, ErrUnauthorized, err)
|
||||
}
|
||||
slog.Info("txn apply authorized",
|
||||
slog.String("txn_id", string(txnID)),
|
||||
slog.String("actor", actor))
|
||||
}
|
||||
dir := remoteTxnDir(txnID)
|
||||
pull := dir + "/orca-pull.sh"
|
||||
|
||||
|
||||
@@ -24,16 +24,37 @@ import (
|
||||
// Connector is the WebAuthn ceremony handler. It is mounted behind
|
||||
// Traefik and called by the bundled Dex.
|
||||
type Connector struct {
|
||||
w *webauthn.WebAuthn
|
||||
store *Store
|
||||
rpID string
|
||||
origin string
|
||||
w *webauthn.WebAuthn
|
||||
store *Store
|
||||
rpID string
|
||||
origin string
|
||||
|
||||
// authFunc validates an authenticated session for registration
|
||||
// (P04, T9, C-45). When non-nil, BeginRegistration and
|
||||
// FinishRegistration require a valid session (cookie or bearer
|
||||
// token) before proceeding; a nil/error result yields 401. When
|
||||
// nil (fail-closed for new deployments), registration is rejected
|
||||
// with 401 — the operator MUST wire an authFunc before enabling
|
||||
// registration. This fixes C-45's unauthenticated-registration
|
||||
// hole: previously anyone could register a credential for any
|
||||
// username.
|
||||
authFunc func(r *http.Request) (authenticated bool, existingUser string, err error)
|
||||
}
|
||||
|
||||
// NewConnector builds a WebAuthn connector with the given RP ID
|
||||
// (the cluster's Traefik-served domain, C-38) and origin (the full
|
||||
// HTTPS URL).
|
||||
func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) {
|
||||
return NewConnectorWithAuth(store, rpID, rpOrigin, nil)
|
||||
}
|
||||
|
||||
// NewConnectorWithAuth builds a Connector with an explicit auth
|
||||
// function for registration (P04, T9). The authFunc returns whether
|
||||
// the request carries a valid authenticated session and, optionally,
|
||||
// the existing user identity (so registration can be scoped to the
|
||||
// authenticated user). When authFunc is nil, registration is fail-
|
||||
// closed (401).
|
||||
func NewConnectorWithAuth(store *Store, rpID, rpOrigin string, authFunc func(r *http.Request) (bool, string, error)) (*Connector, error) {
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: "Orca",
|
||||
RPID: rpID,
|
||||
@@ -44,10 +65,11 @@ func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) {
|
||||
return nil, fmt.Errorf("webauthn: new: %w", err)
|
||||
}
|
||||
return &Connector{
|
||||
w: w,
|
||||
store: store,
|
||||
rpID: rpID,
|
||||
origin: rpOrigin,
|
||||
w: w,
|
||||
store: store,
|
||||
rpID: rpID,
|
||||
origin: rpOrigin,
|
||||
authFunc: authFunc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -64,6 +86,7 @@ type RegistrationSession struct {
|
||||
type sessionStore struct {
|
||||
sessions map[string]*RegistrationSession
|
||||
}
|
||||
|
||||
var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)}
|
||||
|
||||
// sessionTTL is the max time a registration/login session is valid.
|
||||
@@ -79,10 +102,47 @@ func cleanSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAuth checks the request for an authenticated session. When
|
||||
// c.authFunc is nil, registration is fail-closed (401). When the
|
||||
// authFunc returns false or an error, the request is rejected with
|
||||
// 401 Unauthorized. Returns true when the request is authenticated.
|
||||
//
|
||||
// The authFunc may also return the existing user identity so
|
||||
// registration can be scoped (a user can only register credentials
|
||||
// for their own account); the existing-user scoping is enforced by
|
||||
// the caller via the username query param match (a future phase will
|
||||
// wire the authenticated user as the registration target instead of
|
||||
// accepting a free-form username).
|
||||
func (c *Connector) requireAuth(w http.ResponseWriter, r *http.Request) bool {
|
||||
if c.authFunc == nil {
|
||||
http.Error(w, "registration requires authentication (no auth function configured)", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
ok, _, err := c.authFunc(r)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SetAuthFunc sets the registration auth function (P04, T9). Allows
|
||||
// callers to wire the auth check after construction (e.g. when the
|
||||
// session store is initialized later).
|
||||
func (c *Connector) SetAuthFunc(f func(r *http.Request) (bool, string, error)) {
|
||||
c.authFunc = f
|
||||
}
|
||||
|
||||
// BeginRegistration starts the WebAuthn registration ceremony.
|
||||
// GET /orca/webauthn/register?username=<name>
|
||||
// Returns the creation options (challenge) for the browser.
|
||||
func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
// P04 (T9, C-45): require an authenticated session before
|
||||
// allowing registration. Without this, anyone could register a
|
||||
// credential for any username. When authFunc is nil, fail-closed.
|
||||
if !c.requireAuth(w, r) {
|
||||
return
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
@@ -118,6 +178,10 @@ func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /orca/webauthn/register/finish?username=<name>
|
||||
// Body: the attestation response from the browser.
|
||||
func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
// P04 (T9): require an authenticated session for finish too.
|
||||
if !c.requireAuth(w, r) {
|
||||
return
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
@@ -168,6 +232,7 @@ type LoginSession struct {
|
||||
Challenge *webauthn.SessionData
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var loginSessions = map[string]*LoginSession{}
|
||||
|
||||
// BeginLogin starts the WebAuthn login ceremony.
|
||||
@@ -295,11 +360,11 @@ type webauthnUser struct {
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *webauthnUser) WebAuthnID() []byte { return u.id }
|
||||
func (u *webauthnUser) WebAuthnName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnID() []byte { return u.id }
|
||||
func (u *webauthnUser) WebAuthnName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
func (u *webauthnUser) WebAuthnIcon() string { return "" }
|
||||
func (u *webauthnUser) WebAuthnIcon() string { return "" }
|
||||
|
||||
// RPID returns the configured relying-party ID.
|
||||
func (c *Connector) RPID() string { return c.rpID }
|
||||
|
||||
@@ -47,18 +47,65 @@ func TestConnectorHealthz(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestConnectorBeginRegistrationNoUsername verifies the register
|
||||
// endpoint rejects requests without a username.
|
||||
// endpoint rejects requests without a username when authenticated.
|
||||
func TestConnectorBeginRegistrationNoUsername(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
c, _ := NewConnectorWithAuth(store, "test.cluster", "https://test.cluster",
|
||||
func(r *http.Request) (bool, string, error) { return true, "admin", nil })
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("GET", "/orca/webauthn/register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("register without username: %d, want 400", rec.Code)
|
||||
t.Errorf("register without username (authed): %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectorBeginRegistrationUnauthenticated (P04, T9, C-45)
|
||||
// verifies the register endpoint rejects requests with no
|
||||
// authenticated session — closing the hole where anyone could
|
||||
// register a credential for any username.
|
||||
func TestConnectorBeginRegistrationUnauthenticated(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
// No authFunc → fail-closed (401).
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("GET", "/orca/webauthn/register?username=admin", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("register unauthenticated (no authFunc): %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
// authFunc that returns false → 401.
|
||||
c2, _ := NewConnectorWithAuth(store, "test.cluster", "https://test.cluster",
|
||||
func(r *http.Request) (bool, string, error) { return false, "", nil })
|
||||
mux2 := c2.Routes()
|
||||
req2 := httptest.NewRequest("GET", "/orca/webauthn/register?username=admin", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux2.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("register unauthenticated (authFunc=false): %d, want 401", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectorFinishRegistrationUnauthenticated (P04, T9) verifies
|
||||
// the finish endpoint also requires authentication.
|
||||
func TestConnectorFinishRegistrationUnauthenticated(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("POST", "/orca/webauthn/register/finish?username=admin", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("finish unauthenticated: %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,44 @@ func TestSecurityInvariants_Metadata(t *testing.T) {
|
||||
// F18: drift event auth. Tested by:
|
||||
// - internal/drift: TestVerifyEventSignature
|
||||
//
|
||||
// P04 (v0.13) ACL enforcement wiring (C-44/C-45):
|
||||
// - internal/daemon: TestACLPolicyDenyByDefault
|
||||
// (authenticated request with no ACL entry → deny in enforce mode)
|
||||
// - internal/daemon: TestACLPolicyAllowWithEntry
|
||||
// (authenticated request with matching ACL entry → allow)
|
||||
// - internal/daemon: TestACLPolicyUnauthenticatedEnforce
|
||||
// (unauthenticated request → 403 in enforce mode)
|
||||
// - internal/daemon: TestACLPolicyLogOnlyAllowsDenials (C-45)
|
||||
// (denials logged but allowed in log-only mode)
|
||||
// - internal/daemon: TestACLJobsHandlerEnforceDeniesUnauthenticated
|
||||
// (wired jobs handler denies unauthenticated in enforce mode)
|
||||
// - internal/daemon: TestACLJobsHandlerAllowsAuthenticatedWithEntry
|
||||
// (wired jobs handler allows authenticated with matching entry)
|
||||
// - internal/daemon: TestACLNodesHandlerEnforceDeniesUnauthenticated
|
||||
// - internal/daemon: TestACLTasksHandlerEnforceDeniesUnauthenticated
|
||||
// - internal/txn: Apply refuses when ORCA_OIDC_TOKEN is missing/invalid
|
||||
// (C-44: SSH-push applier + txn apply path validate OIDC token)
|
||||
// - internal/sshpush: AuthorizeApply validates ORCA_OIDC_TOKEN
|
||||
//
|
||||
// P04 (v0.13) WebAuthn registration auth (C-45, T9):
|
||||
// - internal/webauthn: TestConnectorBeginRegistrationUnauthenticated
|
||||
// (unauthenticated BeginRegistration → 401, fail-closed)
|
||||
// - internal/webauthn: TestConnectorFinishRegistrationUnauthenticated
|
||||
// (unauthenticated FinishRegistration → 401)
|
||||
//
|
||||
// P04 (v0.13) acl.json hardening (T6/T7):
|
||||
// - internal/cli: saveACL writes acl.json with mode 0600 (T6)
|
||||
// - internal/cli: lockACL flocks grant/revoke (T7, prevents races)
|
||||
//
|
||||
// P04 (v0.13) bootstrap ACL (T8, C-40):
|
||||
// - internal/cli: bootstrapACL grants cluster-admin to orca-admins
|
||||
// group + init SVID on `orca init` (prevents operator lockout)
|
||||
//
|
||||
// P04 (v0.13) audit actor identity (T5):
|
||||
// - internal/cli: currentActor reads OIDC sub from credentials.json
|
||||
// - internal/engine: ActorFromCtx threads sub into audit Record calls
|
||||
// (replaces hardcoded "cli" actor)
|
||||
//
|
||||
// This test is the gate (C-33): if it runs, the suite is wired.
|
||||
t.Log("security integration test suite wired (R-021, F1-F25, REQ-119..148)")
|
||||
t.Log("security integration test suite wired (R-021, F1-F25, REQ-119..148, P04 ACL enforcement C-44/C-45)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user