diff --git a/internal/acl/acl.go b/internal/acl/acl.go new file mode 100644 index 0000000..508f141 --- /dev/null +++ b/internal/acl/acl.go @@ -0,0 +1,152 @@ +// Package acl implements the orca access-control layer (P02, v0.11). +// +// An Identity is either a SPIFFE workload identity (verified SVID whose +// URI is spiffe://orca.local/ns//sa//) or an operator +// token (a bare token ID carrying an explicit namespace claim). Each +// identity is granted a set of Permissions on a namespace; checks are +// deny-by-default — if no entry matches the (identity, namespace) +// pair the check returns false. +package acl + +import ( + "fmt" + "net/url" + "strings" + "sync" +) + +const ( + KindSpiffe = "spiffe" + KindToken = "token" +) + +// Permission is a bitmask of access rights on a namespace. +type Permission uint8 + +// Permission flags. Admin implies Read and Write. +const ( + PermRead Permission = 1 + PermWrite Permission = 2 + PermAdmin Permission = 4 +) + +// AllPermissions is the union of Read + Write + Admin. +const AllPermissions Permission = PermRead | PermWrite | PermAdmin + +// Identity is a principal recognized by the ACL layer. Kind is one of +// KindSpiffe / KindToken. ID is the SPIFFE URI (for spiffe identities) +// or the token ID (for token identities). Namespace is the namespace +// scope — for a SPIFFE identity it is extracted from the URI path; +// for a token it is the namespace claim set at creation time. +type Identity struct { + Kind string `json:"kind"` + ID string `json:"id"` + Namespace string `json:"namespace"` +} + +// ACLEntry binds an Identity to a Namespace with a Permission set. +// A single identity may have at most one entry per namespace; granting +// again on the same namespace replaces the permissions. +type ACLEntry struct { + Identity Identity `json:"identity"` + Namespace string `json:"namespace"` + Permissions Permission `json:"permissions"` +} + +// ACL is a thread-safe list of ACLEntry. Deny-by-default: an identity +// with no matching entry has no permissions. +type ACL struct { + mu sync.RWMutex + entries []ACLEntry +} + +// NewACL returns an empty ACL. +func NewACL() *ACL { + return &ACL{entries: make([]ACLEntry, 0)} +} + +// Grant adds or replaces the entry for (identity, ns). If an entry +// already exists for the same identity (matching Kind+ID) on the same +// namespace, its Permissions are overwritten. +func (a *ACL) Grant(identity Identity, ns string, perms Permission) { + a.mu.Lock() + defer a.mu.Unlock() + for i, e := range a.entries { + if e.Identity.Kind == identity.Kind && e.Identity.ID == identity.ID && e.Namespace == ns { + a.entries[i].Permissions = perms + return + } + } + a.entries = append(a.entries, ACLEntry{ + Identity: identity, + Namespace: ns, + Permissions: perms, + }) +} + +// Revoke removes the entry for (identity, ns) if present. Revoking a +// non-existent entry is a no-op. +func (a *ACL) Revoke(identity Identity, ns string) { + a.mu.Lock() + defer a.mu.Unlock() + for i, e := range a.entries { + if e.Identity.Kind == identity.Kind && e.Identity.ID == identity.ID && e.Namespace == ns { + a.entries = append(a.entries[:i], a.entries[i+1:]...) + return + } + } +} + +// Check reports whether identity has perm on ns. Admin implies Read and +// Write: an admin entry satisfies Read and Write checks. Returns false +// (deny-by-default) if no entry matches. +func (a *ACL) Check(identity Identity, ns string, perm Permission) bool { + a.mu.RLock() + defer a.mu.RUnlock() + for _, e := range a.entries { + if e.Identity.Kind != identity.Kind || e.Identity.ID != identity.ID || e.Namespace != ns { + continue + } + if e.Permissions&perm != 0 { + return true + } + if e.Permissions&PermAdmin != 0 && (perm == PermRead || perm == PermWrite) { + return true + } + return false + } + return false +} + +// List returns a copy of all entries. The slice is safe to mutate. +func (a *ACL) List() []ACLEntry { + a.mu.RLock() + defer a.mu.RUnlock() + out := make([]ACLEntry, len(a.entries)) + copy(out, a.entries) + return out +} + +// SpiffeNamespace extracts the namespace from a SPIFFE URI of the +// form spiffe:///ns//sa//. It accepts +// any trust domain (the caller is expected to have verified the SVID +// against the expected trust domain via identity.VerifySVID). Returns +// an error if the URI is not a valid spiffe:// URI or the path does +// not match the ns//sa// shape. +func SpiffeNamespace(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("acl: parse spiffe uri: %w", err) + } + if u.Scheme != "spiffe" { + return "", fmt.Errorf("acl: not a spiffe uri: %q", uri) + } + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + if len(parts) != 5 || parts[0] != "ns" || parts[2] != "sa" { + return "", fmt.Errorf("acl: malformed spiffe path %q", u.Path) + } + if parts[1] == "" { + return "", fmt.Errorf("acl: empty namespace in spiffe path %q", u.Path) + } + return parts[1], nil +} diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go new file mode 100644 index 0000000..821f8c4 --- /dev/null +++ b/internal/acl/acl_test.go @@ -0,0 +1,234 @@ +package acl + +import ( + "fmt" + "sync" + "testing" +) + +func TestGrantAndCheck(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermRead) + if !a.Check(id, "test", PermRead) { + t.Errorf("Check(Read) = false, want true after Grant(Read)") + } + if a.Check(id, "test", PermWrite) { + t.Errorf("Check(Write) = true, want false (only Read granted)") + } +} + +func TestRevoke(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermRead) + a.Revoke(id, "test") + if a.Check(id, "test", PermRead) { + t.Errorf("Check(Read) = true after Revoke, want false") + } + if got := a.List(); len(got) != 0 { + t.Errorf("List() len = %d after Revoke, want 0", len(got)) + } +} + +func TestRevokeNonExistentNoOp(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Revoke(id, "ghost") + if got := a.List(); len(got) != 0 { + t.Errorf("List() len = %d after no-op Revoke, want 0", len(got)) + } +} + +func TestDenyByDefault(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + if a.Check(id, "test", PermRead) { + t.Errorf("Check on un-granted identity = true, want false (deny-by-default)") + } + if a.Check(id, "test", PermWrite) { + t.Errorf("Check Write on un-granted identity = true, want false") + } + if a.Check(id, "test", PermAdmin) { + t.Errorf("Check Admin on un-granted identity = true, want false") + } +} + +func TestNamespaceIsolation(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "ns-A"} + a.Grant(id, "ns-A", PermRead) + if !a.Check(id, "ns-A", PermRead) { + t.Errorf("Check on ns-A = false, want true") + } + if a.Check(id, "ns-B", PermRead) { + t.Errorf("Check on ns-B = true, want false (namespace isolation)") + } +} + +func TestGrantReplacesPermissions(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermRead) + a.Grant(id, "test", PermWrite) + if a.Check(id, "test", PermRead) { + t.Errorf("Check(Read) = true after re-grant with Write-only, want false") + } + if !a.Check(id, "test", PermWrite) { + t.Errorf("Check(Write) = false after re-grant, want true") + } + if got := a.List(); len(got) != 1 { + t.Errorf("List() len = %d, want 1 (grant replaces, not appends)", len(got)) + } +} + +func TestSpiffeNamespace(t *testing.T) { + got, err := SpiffeNamespace("spiffe://orca.local/ns/myapp/sa/svc1/alloc-123") + if err != nil { + t.Fatalf("SpiffeNamespace: %v", err) + } + if got != "myapp" { + t.Errorf("SpiffeNamespace = %q, want %q", got, "myapp") + } +} + +func TestSpiffeNamespace_OtherTrustDomain(t *testing.T) { + got, err := SpiffeNamespace("spiffe://example.com/ns/prod/sa/api/0") + if err != nil { + t.Fatalf("SpiffeNamespace: %v", err) + } + if got != "prod" { + t.Errorf("SpiffeNamespace = %q, want %q", got, "prod") + } +} + +func TestSpiffeNamespace_Malformed(t *testing.T) { + cases := []string{ + "https://orca.local/ns/prod/sa/api/0", + "spiffe://orca.local/ns/prod/api/0", + "spiffe://orca.local/ns/prod/sa/api", + "spiffe://orca.local/ns//sa/api/0", + ":::not-a-uri", + } + for _, c := range cases { + if _, err := SpiffeNamespace(c); err == nil { + t.Errorf("SpiffeNamespace(%q): expected error, got nil", c) + } + } +} + +func TestPermissionsDistinct(t *testing.T) { + if PermRead == PermWrite || PermRead == PermAdmin || PermWrite == PermAdmin { + t.Errorf("permission flags collide: read=%d write=%d admin=%d", PermRead, PermWrite, PermAdmin) + } + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermRead|PermWrite) + if !a.Check(id, "test", PermRead) { + t.Errorf("Check(Read) for read+write grant = false, want true") + } + if !a.Check(id, "test", PermWrite) { + t.Errorf("Check(Write) for read+write grant = false, want true") + } + if a.Check(id, "test", PermAdmin) { + t.Errorf("Check(Admin) for read+write grant = true, want false") + } +} + +func TestAdminImpliesReadAndWrite(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermAdmin) + if !a.Check(id, "test", PermAdmin) { + t.Errorf("Check(Admin) = false, want true") + } + if !a.Check(id, "test", PermRead) { + t.Errorf("Check(Read) for admin grant = false, want true (admin implies read)") + } + if !a.Check(id, "test", PermWrite) { + t.Errorf("Check(Write) for admin grant = false, want true (admin implies write)") + } +} + +func TestConcurrentAccess(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-concurrent", Namespace: "ns"} + const n = 200 + var wg sync.WaitGroup + wg.Add(n * 3) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + a.Grant(id, "ns", PermRead|PermWrite) + }() + go func() { + defer wg.Done() + a.Check(id, "ns", PermRead) + }() + go func() { + defer wg.Done() + a.List() + }() + } + wg.Wait() + if !a.Check(id, "ns", PermRead) { + t.Errorf("Check(Read) after concurrent grants = false, want true") + } + if got := a.List(); len(got) != 1 { + t.Errorf("List() len = %d, want 1 (concurrent grants replace, not append)", len(got)) + } +} + +func TestListIsCopy(t *testing.T) { + a := NewACL() + id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"} + a.Grant(id, "test", PermRead) + lst := a.List() + lst[0].Permissions = PermAdmin + if a.Check(id, "test", PermAdmin) { + t.Errorf("mutating List() result leaked into ACL: %v", a.List()) + } +} + +func TestSpiffeIdentityGrant(t *testing.T) { + a := NewACL() + uri := "spiffe://orca.local/ns/myapp/sa/svc1/alloc-123" + ns, err := SpiffeNamespace(uri) + if err != nil { + t.Fatalf("SpiffeNamespace: %v", err) + } + id := Identity{Kind: KindSpiffe, ID: uri, Namespace: ns} + a.Grant(id, ns, PermRead|PermWrite) + if !a.Check(id, ns, PermRead) || !a.Check(id, ns, PermWrite) { + t.Errorf("spiffe identity check failed for ns=%s", ns) + } +} + +func TestTokenAndSpiffeIdentitiesIndependent(t *testing.T) { + a := NewACL() + uri := "spiffe://orca.local/ns/prod/sa/api/0" + spiffeID := Identity{Kind: KindSpiffe, ID: uri, Namespace: "prod"} + tokenID := Identity{Kind: KindToken, ID: "operator-1", Namespace: "prod"} + a.Grant(spiffeID, "prod", PermRead) + if a.Check(tokenID, "prod", PermRead) { + t.Errorf("token identity matched spiffe grant (kind isolation broken)") + } + if !a.Check(spiffeID, "prod", PermRead) { + t.Errorf("spiffe identity check failed") + } + if got := a.List(); len(got) != 1 { + t.Errorf("List() len = %d, want 1", len(got)) + } +} + +func TestAllPermissionsConstant(t *testing.T) { + if AllPermissions != PermRead|PermWrite|PermAdmin { + t.Errorf("AllPermissions = %d, want %d", AllPermissions, PermRead|PermWrite|PermAdmin) + } +} + +func ExampleSpiffeNamespace() { + ns, _ := SpiffeNamespace("spiffe://orca.local/ns/myapp/sa/svc1/alloc-123") + fmt.Println(ns) + // Output: myapp +} diff --git a/internal/cli/acl.go b/internal/cli/acl.go new file mode 100644 index 0000000..65511a2 --- /dev/null +++ b/internal/cli/acl.go @@ -0,0 +1,366 @@ +// Package cli: acl.go implements the `orca acl` subcommand family +// (P02, v0.11). Subcommands: +// +// orca acl grant --namespace --permissions +// orca acl revoke --namespace +// orca acl list +// orca acl check --namespace --permission +// +// ACL state is stored at paths.ClusterDir()/acl.json (a simple JSON +// file — no DB needed for v0.11). 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" +) + +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//sa//) 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 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.KindToken, 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) + } + if err := writeAtomicFile(path, data, 0o644); err != nil { + return fmt.Errorf("write acl state: %w", err) + } + return nil +} + +// 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). +func writeAtomicFile(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".acl-tmp-*") + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp: %w", err) + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename temp: %w", err) + } + return nil +} + +var aclGrantCmd = &cobra.Command{ + Use: "grant ", + 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 + } + 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 ", + 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") + } + 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 ", + 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) +} diff --git a/internal/cli/acl_test.go b/internal/cli/acl_test.go new file mode 100644 index 0000000..5f92faa --- /dev/null +++ b/internal/cli/acl_test.go @@ -0,0 +1,386 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/paths" +) + +func resetACLFlags() { + aclGrantNamespace = "" + aclGrantPermissions = "read" + aclRevokeNamespace = "" + aclCheckNamespace = "" + aclCheckPermission = "read" +} + +func TestACLCommandRegistered(t *testing.T) { + registered := make(map[string]bool) + for _, cmd := range rootCmd.Commands() { + registered[cmd.Name()] = true + } + if !registered["acl"] { + t.Fatal("acl command not registered on root") + } +} + +func TestACLSubcommands(t *testing.T) { + expected := []string{"grant", "revoke", "list", "check"} + registered := make(map[string]bool) + for _, cmd := range aclCmd.Commands() { + registered[cmd.Name()] = true + } + for _, name := range expected { + if !registered[name] { + t.Errorf("expected acl subcommand %q not registered", name) + } + } +} + +func TestParseIdentity_Spiffe(t *testing.T) { + id, err := parseIdentity("spiffe://orca.local/ns/myapp/sa/svc1/alloc-1") + if err != nil { + t.Fatalf("parseIdentity: %v", err) + } + if id.Kind != "spiffe" { + t.Errorf("kind = %q, want spiffe", id.Kind) + } + if id.Namespace != "myapp" { + t.Errorf("namespace = %q, want myapp", id.Namespace) + } +} + +func TestParseIdentity_Token(t *testing.T) { + id, err := parseIdentity("operator-1") + if err != nil { + t.Fatalf("parseIdentity: %v", err) + } + if id.Kind != "token" { + t.Errorf("kind = %q, want token", id.Kind) + } + if id.ID != "operator-1" { + t.Errorf("id = %q, want operator-1", id.ID) + } + if id.Namespace != "" { + t.Errorf("namespace = %q, want empty (set via --namespace)", id.Namespace) + } +} + +func TestParseIdentity_Empty(t *testing.T) { + if _, err := parseIdentity(""); err == nil { + t.Errorf("parseIdentity(\"\"): expected error, got nil") + } +} + +func TestParsePermissions(t *testing.T) { + cases := []struct { + in string + want uint8 + }{ + {"", 1}, + {"read", 1}, + {"write", 2}, + {"admin", 4}, + {"read,write", 3}, + {"read,write,admin", 7}, + {"READ,Write", 3}, + } + for _, c := range cases { + got, err := parsePermissions(c.in) + if err != nil { + t.Errorf("parsePermissions(%q): unexpected err %v", c.in, err) + continue + } + if uint8(got) != c.want { + t.Errorf("parsePermissions(%q) = %d, want %d", c.in, uint8(got), c.want) + } + } +} + +func TestParsePermissions_Unknown(t *testing.T) { + if _, err := parsePermissions("read,delete"); err == nil { + t.Errorf("parsePermissions(read,delete): expected error, got nil") + } +} + +func TestACLGrantAndCheck(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read,write"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("acl grant: %v", err) + } + + if _, err := os.Stat(paths.ACLPath()); err != nil { + t.Fatalf("acl.json not written: %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("acl check read: %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "admin"}) + err := rootCmd.Execute() + if err == nil { + t.Fatalf("acl check admin: expected denied error, got nil") + } +} + +func TestACLCheckDeniedExits1(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "check", "ghost", "--namespace", "prod", "--permission", "read"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected denied error for un-granted identity, got nil") + } +} + +func TestACLRevoke(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) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "revoke", "operator-1", "--namespace", "prod"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("revoke: %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"}) + if err := rootCmd.Execute(); err == nil { + t.Fatalf("check after revoke: expected denied, got nil") + } +} + +func TestACLListEmpty(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"acl", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("acl list empty: %v", err) + } + if !strings.Contains(buf.String(), "No ACL entries") { + t.Errorf("acl list empty: %s", buf.String()) + } +} + +func TestACLListWithEntries(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "read,write"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("grant: %v", err) + } + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "myapp", "--permissions", "admin"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("grant spiffe: %v", err) + } + + resetRootFlags(t) + resetACLFlags() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"acl", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("acl list: %v", err) + } + out := buf.String() + if !strings.Contains(out, "operator-1") || !strings.Contains(out, "prod") { + t.Errorf("list missing operator-1/prod: %s", out) + } + if !strings.Contains(out, "spiffe://orca.local/ns/myapp") || !strings.Contains(out, "myapp") { + t.Errorf("list missing spiffe entry: %s", out) + } + if !strings.Contains(out, "read,write") || !strings.Contains(out, "admin") { + t.Errorf("list missing permissions: %s", out) + } +} + +func TestACLListJSON(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) + } + + resetRootFlags(t) + resetACLFlags() + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"acl", "list", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("acl list --json: %v", err) + } + var entries []map[string]any + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &entries); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + if len(entries) != 1 { + t.Fatalf("entries len = %d, want 1", len(entries)) + } + id, _ := entries[0]["identity"].(map[string]any) + if id == nil || id["id"] != "operator-1" { + t.Errorf("identity = %v, want operator-1", entries[0]["identity"]) + } +} + +func TestACLGrantSpiffeNamespaceMismatch(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "other"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected mismatch error, got nil") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error = %q, want contains 'does not match'", err.Error()) + } +} + +func TestACLGrantSpiffeDefaultsNamespaceFromPath(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--permissions", "read"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("grant spiffe (no --namespace): %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "spiffe://orca.local/ns/myapp/sa/svc1/alloc-1", "--namespace", "myapp", "--permission", "read"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("check spiffe: %v", err) + } +} + +func TestACLGrantTokenRequiresNamespace(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--permissions", "read"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for token grant without --namespace, got nil") + } +} + +func TestACLStatePersists(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) + } + + data, err := os.ReadFile(paths.ACLPath()) + if err != nil { + t.Fatalf("read acl.json: %v", err) + } + if !strings.Contains(string(data), "operator-1") || !strings.Contains(string(data), "prod") { + t.Errorf("acl.json missing entry: %s", string(data)) + } +} + +func TestACLAtomicWriteNoPartialFile(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) + } + entries, err := os.ReadDir(filepath.Dir(paths.ACLPath())) + if err != nil { + t.Fatalf("readdir cluster: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".acl-tmp-") { + t.Errorf("leftover temp file: %s", e.Name()) + } + } +} + +func TestACLCheckJSONDenied(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"acl", "check", "ghost", "--namespace", "prod", "--permission", "read", "--json"}) + _ = rootCmd.Execute() + 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["allowed"] != false { + t.Errorf("allowed = %v, want false", result["allowed"]) + } +} + +func TestACLAdminImpliesReadCheck(t *testing.T) { + t.Setenv("ORCA_HOME", t.TempDir()) + resetRootFlags(t) + resetACLFlags() + + rootCmd.SetArgs([]string{"acl", "grant", "operator-1", "--namespace", "prod", "--permissions", "admin"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("grant admin: %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "read"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("check read (admin grant): %v", err) + } + + resetRootFlags(t) + resetACLFlags() + rootCmd.SetArgs([]string{"acl", "check", "operator-1", "--namespace", "prod", "--permission", "write"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("check write (admin grant): %v", err) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index eea7041..ce55e77 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -119,3 +119,7 @@ func ConfigPath() string { return filepath.Join(ClusterDir(), "config.md") } // LegacyHCLConfigPath returns the legacy HCL config path: // ClusterDir()/config.hcl. func LegacyHCLConfigPath() string { return filepath.Join(ClusterDir(), "config.hcl") } + +// ACLPath returns the cluster-wide ACL state file (P02, v0.11): +// ClusterDir()/acl.json. A simple JSON file — no DB needed for v0.11. +func ACLPath() string { return filepath.Join(ClusterDir(), "acl.json") }