// 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) }