feat(P02): ACL — SPIFFE + token identities, deny-by-default
internal/acl/acl.go: Identity, Permission, ACLEntry, ACL with Grant/Revoke/Check/List; SpiffeNamespace extraction; deny-by-default. internal/cli/acl.go: orca acl grant/revoke/list/check CLI; state at cluster/acl.json. Tests: grant/revoke/deny/ns-isolation/concurrent. ---ci--- project: orca phase: 02 milestone: v0.11 status: execute ---/ci---
This commit is contained in:
@@ -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/<ns>/sa/<sa>/<alloc>) 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://<trust-domain>/ns/<ns>/sa/<sa>/<alloc-id>. 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/<ns>/sa/<sa>/<alloc-id> 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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.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 <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
|
||||
}
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
Reference in New Issue
Block a user