0358efe95b
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs - secrets file flock (concurrent set on same ns no longer loses data) - upgrade lock file (refuse concurrent orca upgrade) - backup lock file (refuse concurrent backup) - cache invalidation by writes (read-after-write consistency) - Executor.Run mutex scope fix (hold only for DB inserts) - ns create/inherit/set-constraint atomic writeNSMdAtomic - writeCurrentLead + rotateSSHKeys atomic - consolidate 3 writeAtomic impls onto security.WriteAtomic - WebAuthn session stores guarded with sync.Mutex Tests: concurrent secrets set, upgrade lock rejection, cache read-after-write, WebAuthn session thread-safety (pass under -race). ---ci--- project: orca phase: 7 milestone: v0.13 status: complete requirements: covered: [156] ---/ci---
384 lines
12 KiB
Go
384 lines
12 KiB
Go
// Package cli: acl.go implements the `orca acl` subcommand family
|
|
// (P02, v0.11). Subcommands:
|
|
//
|
|
// orca acl grant <identity> --namespace <ns> --permissions <perms>
|
|
// orca acl revoke <identity> --namespace <ns>
|
|
// orca acl list
|
|
// orca acl check <identity> --namespace <ns> --permission <perm>
|
|
//
|
|
// ACL state is stored at paths.ClusterDir()/acl.json (a simple JSON
|
|
// file — no DB needed for v0.11). <identity> is either a SPIFFE URI
|
|
// (spiffe://orca.local/ns/.../sa/.../...) or a bare token ID.
|
|
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/acl"
|
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
|
)
|
|
|
|
var (
|
|
aclGrantNamespace string
|
|
aclGrantPermissions string
|
|
aclRevokeNamespace string
|
|
aclCheckNamespace string
|
|
aclCheckPermission string
|
|
)
|
|
|
|
var aclCmd = &cobra.Command{
|
|
Use: "acl",
|
|
Short: "Manage access-control entries (SPIFFE + token identities)",
|
|
Long: `Manage the cluster ACL (P02, v0.11). Identities are either
|
|
SPIFFE workload URIs (spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc>) or
|
|
operator token IDs. Permissions are deny-by-default: an identity with
|
|
no matching entry on a namespace has no access.
|
|
|
|
State is stored at ` + "`" + `ClusterDir()/acl.json` + "`" + `.`,
|
|
}
|
|
|
|
// parseIdentity classifies <identity> as a SPIFFE or token identity.
|
|
// A SPIFFE identity is detected by the spiffe:// scheme; its namespace
|
|
// is extracted from the URI path. Anything else is treated as a token
|
|
// ID whose namespace must be supplied via the --namespace flag.
|
|
func parseIdentity(raw string) (acl.Identity, error) {
|
|
if strings.HasPrefix(raw, "spiffe://") {
|
|
ns, err := acl.SpiffeNamespace(raw)
|
|
if err != nil {
|
|
return acl.Identity{}, fmt.Errorf("parse spiffe identity: %w", err)
|
|
}
|
|
return acl.Identity{Kind: acl.KindSpiffe, ID: raw, Namespace: ns}, nil
|
|
}
|
|
if raw == "" {
|
|
return acl.Identity{}, fmt.Errorf("identity is empty")
|
|
}
|
|
return acl.Identity{Kind: acl.KindOidc, ID: raw}, nil
|
|
}
|
|
|
|
// parsePermissions parses a comma-separated list of "read","write",
|
|
// "admin" into a Permission bitmask. Empty string defaults to read.
|
|
func parsePermissions(s string) (acl.Permission, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return acl.PermRead, nil
|
|
}
|
|
var perms acl.Permission
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(strings.ToLower(part))
|
|
switch part {
|
|
case "read":
|
|
perms |= acl.PermRead
|
|
case "write":
|
|
perms |= acl.PermWrite
|
|
case "admin":
|
|
perms |= acl.PermAdmin
|
|
default:
|
|
return 0, fmt.Errorf("unknown permission %q (want read, write, or admin)", part)
|
|
}
|
|
}
|
|
if perms == 0 {
|
|
return 0, fmt.Errorf("no permissions in %q", s)
|
|
}
|
|
return perms, nil
|
|
}
|
|
|
|
// permName renders a Permission bitmask as a comma-separated string.
|
|
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, ",")
|
|
}
|
|
|
|
// aclState is the on-disk JSON shape for acl.json.
|
|
type aclState struct {
|
|
Entries []acl.ACLEntry `json:"entries"`
|
|
}
|
|
|
|
// loadACL reads paths.ACLPath() and returns an *acl.ACL. A missing
|
|
// file is treated as an empty ACL (not an error).
|
|
func loadACL() (*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
|
|
}
|
|
|
|
// saveACL writes the ACL to paths.ACLPath() atomically (write to temp,
|
|
// rename). The cluster dir is created if missing.
|
|
func saveACL(a *acl.ACL) error {
|
|
path := paths.ACLPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create cluster dir: %w", err)
|
|
}
|
|
st := aclState{Entries: a.List()}
|
|
data, err := json.MarshalIndent(st, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal acl state: %w", err)
|
|
}
|
|
// 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 atomically (REQ-156, P07 T9).
|
|
// Previously a local copy of the temp+chmod+rename pattern (P02 kept a
|
|
// local copy to avoid importing internal/security); it lacked fsync,
|
|
// so a crash between write and rename could promote a partially-durable
|
|
// file. Now a thin wrapper around the canonical security.WriteAtomic
|
|
// (temp + chmod + fsync + rename) so all CLI atomic writes share one
|
|
// fsync-correct implementation.
|
|
func writeAtomicFile(path string, data []byte, mode os.FileMode) error {
|
|
return security.WriteAtomic(path, mode, data)
|
|
}
|
|
|
|
var aclGrantCmd = &cobra.Command{
|
|
Use: "grant <identity>",
|
|
Short: "Grant permissions to an identity on a namespace",
|
|
Long: `Grant permissions to an identity on a namespace. The identity
|
|
is either a SPIFFE URI (its namespace is extracted from the path and
|
|
must match --namespace) or a bare token ID (whose namespace is
|
|
--namespace). --permissions is a comma-separated list of read,write,
|
|
admin (default: read).`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
identity, err := parseIdentity(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ns := aclGrantNamespace
|
|
if ns == "" {
|
|
ns = identity.Namespace
|
|
}
|
|
if ns == "" {
|
|
return fmt.Errorf("--namespace is required for token identities (or set it to match the spiffe path)")
|
|
}
|
|
if identity.Kind == acl.KindSpiffe && identity.Namespace != "" && identity.Namespace != ns {
|
|
return fmt.Errorf("spiffe namespace %q does not match --namespace %q", identity.Namespace, ns)
|
|
}
|
|
perms, err := parsePermissions(aclGrantPermissions)
|
|
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
|
|
}
|
|
identity.Namespace = ns
|
|
a.Grant(identity, ns, perms)
|
|
if err := saveACL(a); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("acl grant", "identity", identity.ID, "namespace", ns, "permissions", permName(perms))
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{
|
|
"identity": identity,
|
|
"namespace": ns,
|
|
"permissions": permName(perms),
|
|
"granted": true,
|
|
})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Granted %s on %s to %s\n", permName(perms), ns, identity.ID)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var aclRevokeCmd = &cobra.Command{
|
|
Use: "revoke <identity>",
|
|
Short: "Revoke an identity's access on a namespace",
|
|
Long: `Revoke an identity's entry on a namespace. For a SPIFFE
|
|
identity the namespace defaults to the one in the URI path; for a
|
|
token identity --namespace is required.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
identity, err := parseIdentity(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ns := aclRevokeNamespace
|
|
if ns == "" {
|
|
ns = identity.Namespace
|
|
}
|
|
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
|
|
}
|
|
identity.Namespace = ns
|
|
a.Revoke(identity, ns)
|
|
if err := saveACL(a); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("acl revoke", "identity", identity.ID, "namespace", ns)
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{
|
|
"identity": identity,
|
|
"namespace": ns,
|
|
"revoked": true,
|
|
})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Revoked %s on %s\n", identity.ID, ns)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var aclListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all ACL entries",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
a, err := loadACL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries := a.List()
|
|
if jsonOutput {
|
|
return printJSON(entries)
|
|
}
|
|
out := cmd.OutOrStdout()
|
|
if len(entries) == 0 {
|
|
fmt.Fprintln(out, "No ACL entries. Use `orca acl grant` to add one.")
|
|
return nil
|
|
}
|
|
fmt.Fprintf(out, "%-12s %-50s %-16s %s\n", "KIND", "IDENTITY", "NAMESPACE", "PERMISSIONS")
|
|
for _, e := range entries {
|
|
fmt.Fprintf(out, "%-12s %-50s %-16s %s\n", e.Identity.Kind, e.Identity.ID, e.Namespace, permName(e.Permissions))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var aclCheckCmd = &cobra.Command{
|
|
Use: "check <identity>",
|
|
Short: "Check whether an identity has a permission on a namespace",
|
|
Long: `Check whether an identity has the given permission on the
|
|
namespace. Exits 0 if allowed, 1 if denied. --permission is one of
|
|
read, write, admin (default: read).`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
identity, err := parseIdentity(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ns := aclCheckNamespace
|
|
if ns == "" {
|
|
ns = identity.Namespace
|
|
}
|
|
if ns == "" {
|
|
return fmt.Errorf("--namespace is required for token identities")
|
|
}
|
|
permStr := strings.TrimSpace(aclCheckPermission)
|
|
if permStr == "" {
|
|
permStr = "read"
|
|
}
|
|
perm, err := parsePermissions(permStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a, err := loadACL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
identity.Namespace = ns
|
|
allowed := a.Check(identity, ns, perm)
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{
|
|
"identity": identity,
|
|
"namespace": ns,
|
|
"permission": permStr,
|
|
"allowed": allowed,
|
|
})
|
|
}
|
|
if allowed {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s has %s on %s\n", identity.ID, permStr, ns)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✗ %s does NOT have %s on %s\n", identity.ID, permStr, ns)
|
|
return fmt.Errorf("denied")
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
aclGrantCmd.Flags().StringVar(&aclGrantNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
|
|
aclGrantCmd.Flags().StringVar(&aclGrantPermissions, "permissions", "read", "comma-separated permissions: read,write,admin")
|
|
aclRevokeCmd.Flags().StringVar(&aclRevokeNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
|
|
aclCheckCmd.Flags().StringVar(&aclCheckNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
|
|
aclCheckCmd.Flags().StringVar(&aclCheckPermission, "permission", "read", "permission to check: read, write, or admin")
|
|
|
|
aclCmd.AddCommand(aclGrantCmd)
|
|
aclCmd.AddCommand(aclRevokeCmd)
|
|
aclCmd.AddCommand(aclListCmd)
|
|
aclCmd.AddCommand(aclCheckCmd)
|
|
rootCmd.AddCommand(aclCmd)
|
|
}
|