console: deploy role and rung-scoped service credentials (#229)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6 files changed,
+318
−18
internal/console/authstore.go+10 −3internal/console/deploycred_test.go+142 −0internal/console/deployscope.go+76 −0internal/console/svcprincipal.go+62 −13internal/svcrole/svcrole.go+8 −2internal/svcrole/svcrole_test.go+20 −0
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 8098415a1..fd6672677 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -122,8 +122,14 @@ type CLIToken struct { |
| // minted before this column existed, including every legacy kind='app' |
| // row NewAppToken mints — those never rotate through this path. |
| RotatedAt time.Time |
| + // Rungs is a deploy credential's scope in its stored, canonical form |
| + // ("canary,edge"); empty for every other kind and role. |
| + Rungs string |
| } |
| +// RungList is Rungs for a person to read: "canary, edge". |
| +func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") } |
| + |
| // NeverExpires reports whether this credential has no expiry — true only |
| // for app and service tokens, whose expires_at is 0 by design (spec §8). The |
| // page asks this rather than testing ExpiresAt against the epoch itself, so |
| @@ -214,6 +220,7 @@ func (s *Store) migrateCLIAuth() error { |
| `ALTER TABLE cli_tokens ADD COLUMN name TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE cli_tokens ADD COLUMN rotated_at INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -550,7 +557,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| // scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values. |
| // CLITokens (by address) and ServicePrincipals (by account) select the same |
| -// fourteen columns in the same order and need the same legacy-row |
| +// fifteen columns in the same order and need the same legacy-row |
| // normalization — a kind='app' row's `role` column is empty in the database but |
| // IS the instance role (spec §5). This is the one place that normalization |
| // happens, so neither caller, nor any reader downstream of them, has to |
| @@ -562,7 +569,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| var t CLIToken |
| var created, expires, used, revoked, rotated int64 |
| if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| - &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated); err != nil { |
| + &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs); err != nil { |
| return nil, err |
| } |
| t.CreatedAt = time.Unix(created, 0).UTC() |
| @@ -593,7 +600,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| // keep that promise. |
| func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address) |
| if err != nil { |
| return nil, err |
diff --git a/internal/console/deploycred_test.go b/internal/console/deploycred_test.go| new file mode 100644 |
| index 000000000..3a13ddda6 |
| --- /dev/null |
| +++ b/internal/console/deploycred_test.go |
| @@ -0,0 +1,142 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func TestCanonicalRungs(t *testing.T) { |
| + for _, tc := range []struct { |
| + in []string |
| + want string |
| + }{ |
| + {nil, ""}, |
| + {[]string{" ", ""}, ""}, |
| + {[]string{"edge"}, "edge"}, |
| + {[]string{"edge", " canary ", "edge", ""}, "canary,edge"}, |
| + {[]string{"beta", "canary", "edge"}, "canary,edge,beta"}, |
| + } { |
| + got, err := canonicalRungs(tc.in) |
| + if err != nil || got != tc.want { |
| + t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want) |
| + } |
| + } |
| + if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| + t.Errorf("stable: err = %v, want ErrStableNotDelegable", err) |
| + } |
| + for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} { |
| + var ue *UnknownRungError |
| + if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad { |
| + t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err) |
| + } |
| + } |
| +} |
| + |
| +func TestParseStoredRungs(t *testing.T) { |
| + if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) { |
| + t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok) |
| + } |
| + // Not canonical, empty, or naming stable: never a usable scope. |
| + for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} { |
| + if got, ok := parseStoredRungs(bad); ok { |
| + t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got) |
| + } |
| + } |
| +} |
| + |
| +func TestMintDeployRules(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + mint := func(app string, role svcrole.Role, rungs []string) error { |
| + _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now) |
| + return err |
| + } |
| + if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) { |
| + t.Errorf("deploy with no app: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) { |
| + t.Errorf("deploy with no rungs: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| + t.Errorf("deploy with stable: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) { |
| + t.Errorf("publish with rungs: %v", err) |
| + } |
| + // The old entry point cannot mint a deploy credential: it has no rungs. |
| + if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) { |
| + t.Errorf("NewServicePrincipal(deploy): %v", err) |
| + } |
| +} |
| + |
| +func TestDeployPrincipalReadsBackItsRungs(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, ok := st.CLIPrincipal(raw, now) |
| + if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| + t.Fatalf("CLIPrincipal = %+v, %v", p, ok) |
| + } |
| + // Review Focus 3: rotation keeps the rungs, and the old secret dies. |
| + fresh, err := st.RotateServicePrincipal(1, "ci", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Fatal("the old secret still resolves after rotate") |
| + } |
| + p, ok = st.CLIPrincipal(fresh, now) |
| + if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| + t.Fatalf("after rotate: %+v, %v", p, ok) |
| + } |
| + rows, err := st.ServicePrincipals(1) |
| + if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" { |
| + t.Fatalf("ServicePrincipals = %+v, %v", rows, err) |
| + } |
| +} |
| + |
| +func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} { |
| + raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p) |
| + } |
| + } |
| + // A non-deploy row that somehow carries rungs is refused too. |
| + raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Error("a publish row carrying rungs resolved") |
| + } |
| +} |
| + |
| +// newDeployStore is a bare console store, enough for the store-level tests. |
| +func newDeployStore(t *testing.T) *Store { |
| + t.Helper() |
| + st, err := Open(t.TempDir() + "/console.db") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + return st |
| +} |
diff --git a/internal/console/deployscope.go b/internal/console/deployscope.go| new file mode 100644 |
| index 000000000..da4994969 |
| --- /dev/null |
| +++ b/internal/console/deployscope.go |
| @@ -0,0 +1,76 @@ |
| +// The deploy credential's scope: which rungs it may move, and the one |
| +// function that decides whether a promote or rollback by it is in scope. |
| +// |
| +// Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md. |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// deployRungs is the order rungs are stored and shown in. stable is not in |
| +// it: no delegated credential may move stable (spec §5.2). |
| +var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta} |
| + |
| +// ErrStableNotDelegable is a mint that names stable. |
| +var ErrStableNotDelegable = errors.New("console: stable cannot be delegated") |
| + |
| +// UnknownRungError is a mint that names something that is not a rung. It |
| +// carries the name so both mint doors can say which one (D10, D15). |
| +type UnknownRungError struct{ Rung string } |
| + |
| +func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung } |
| + |
| +// canonicalRungs turns what a person typed into the stored form: spaces and |
| +// empty entries dropped, duplicates collapsed, ladder order, joined by ",". |
| +// It returns "" when nothing was named. It never lower-cases: "Edge" is not |
| +// a rung, and accepting it would make the stored form differ from the input |
| +// in a way the minter did not ask for. |
| +func canonicalRungs(in []string) (string, error) { |
| + seen := map[string]bool{} |
| + for _, raw := range in { |
| + r := strings.TrimSpace(raw) |
| + if r == "" { |
| + continue |
| + } |
| + if r == release.RungStable { |
| + return "", ErrStableNotDelegable |
| + } |
| + known := false |
| + for _, d := range deployRungs { |
| + if r == d { |
| + known = true |
| + } |
| + } |
| + if !known { |
| + return "", &UnknownRungError{Rung: r} |
| + } |
| + seen[r] = true |
| + } |
| + var out []string |
| + for _, d := range deployRungs { |
| + if seen[d] { |
| + out = append(out, d) |
| + } |
| + } |
| + return strings.Join(out, ","), nil |
| +} |
| + |
| +// parseStoredRungs reads the column back. Only the exact canonical form of a |
| +// non-empty set is accepted: anything else is a row this console did not |
| +// write, and a deploy credential with an unreadable scope must not resolve |
| +// (spec §5.5). |
| +func parseStoredRungs(s string) ([]string, bool) { |
| + if s == "" { |
| + return nil, false |
| + } |
| + parts := strings.Split(s, ",") |
| + canon, err := canonicalRungs(parts) |
| + if err != nil || canon != s { |
| + return nil, false |
| + } |
| + return parts, true |
| +} |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index d928bd1e8..fb2cbbb0c 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -52,6 +52,10 @@ type Principal struct { |
| App string // "" means account-scoped |
| Role svcrole.Role |
| Name string // "" for a person and for legacy kind='app' rows |
| + // Rungs is a deploy credential's scope, in ladder order. nil for every |
| + // other role, and never empty for a deploy principal: CLIPrincipal |
| + // refuses a deploy row it cannot read a scope from. |
| + Rungs []string |
| } |
| // ErrServiceNameTaken is returned by NewServicePrincipal when the account |
| @@ -75,11 +79,11 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { |
| return Principal{}, false |
| } |
| hash := hashToken(rawToken) |
| - var storedHash, addr, kind, app, role, name string |
| + var storedHash, addr, kind, app, role, name, rungs string |
| var accountID, expires, revoked int64 |
| - err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| FROM cli_tokens WHERE token_hash = ?`, hash). |
| - Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked) |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| if err != nil { |
| return Principal{}, false |
| } |
| @@ -127,7 +131,20 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| if !ok { |
| return Principal{}, false |
| } |
| - p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name} |
| + var scope []string |
| + if r == svcrole.Deploy { |
| + // Fail closed: a deploy row with no readable scope, or with no |
| + // app, is not treated as unscoped (spec §5.5). |
| + scope, ok = parseStoredRungs(rungs) |
| + if !ok || app == "" { |
| + return Principal{}, false |
| + } |
| + } else if rungs != "" { |
| + // Rungs on any other role would read as a limit and enforce |
| + // nothing; a row this console did not write does not resolve. |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope} |
| default: |
| return Principal{}, false |
| } |
| @@ -137,6 +154,22 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| return p, true |
| } |
| +// ErrAccountScopedService is a Service credential minted without an app. It is |
| +// its own sentinel because the mint doors match sentinels rather than strings, |
| +// and a scope refusal is the caller's to fix while a busy database is not. |
| +var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app") |
| + |
| +// ErrAccountScopedDeploy is a deploy credential minted without an app. |
| +var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app") |
| + |
| +// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is |
| +// no default set: the minter says what the credential may move. |
| +var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung") |
| + |
| +// ErrRungsNeedDeploy is rungs named for any other role, where they would |
| +// read as a limit and enforce nothing. |
| +var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs") |
| + |
| // NewServicePrincipal mints a credential a SERVER holds, scoped to one |
| // account and — optionally — one app within it, and carrying exactly one |
| // role from the closed svcrole catalogue. mintedBy is the member who minted |
| @@ -163,12 +196,14 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // rotated_at is stamped equal to created_at at mint: the current secret |
| // began exactly when the credential did. RotateServicePrincipal advances it |
| // on every later rotation without touching created_at. |
| -// ErrAccountScopedService is a Service credential minted without an app. It is |
| -// its own sentinel because the mint doors match sentinels rather than strings, |
| -// and a scope refusal is the caller's to fix while a busy database is not. |
| -var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app") |
| - |
| func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) { |
| + return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now) |
| +} |
| + |
| +// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which |
| +// only a deploy credential takes. The rules live here rather than in a |
| +// handler for the reason the Service rule does: there are two mint doors. |
| +func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) { |
| if name == "" { |
| return "", errors.New("console: a service credential needs a name") |
| } |
| @@ -189,6 +224,20 @@ func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcr |
| if role == svcrole.Service && app == "" { |
| return "", ErrAccountScopedService |
| } |
| + canon, err := canonicalRungs(rungs) |
| + if err != nil { |
| + return "", err |
| + } |
| + if role == svcrole.Deploy { |
| + if app == "" { |
| + return "", ErrAccountScopedDeploy |
| + } |
| + if canon == "" { |
| + return "", ErrDeployNeedsRungs |
| + } |
| + } else if canon != "" { |
| + return "", ErrRungsNeedDeploy |
| + } |
| tx, err := s.db.Begin() |
| if err != nil { |
| return "", err |
| @@ -207,9 +256,9 @@ func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcr |
| return "", err |
| } |
| if _, err := tx.Exec(`INSERT INTO cli_tokens |
| -(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name) |
| -VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?)`, |
| - hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name); err != nil { |
| +(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs) |
| +VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil { |
| return "", err |
| } |
| if err := tx.Commit(); err != nil { |
| @@ -229,7 +278,7 @@ VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?)`, |
| // stays out of CLITokens. |
| func (s *Store) ServicePrincipals(accountID int64) ([]CLIToken, error) { |
| rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| -role, name, created_at, expires_at, last_used, revoked_at, rotated_at |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs |
| FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0 |
| ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp) |
| if err != nil { |
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go| index d0dc2a607..9c0820a95 100644 |
| --- a/internal/svcrole/svcrole.go |
| +++ b/internal/svcrole/svcrole.go |
| @@ -31,6 +31,12 @@ const ( |
| // Publish is the deploy loop: claim an app, ship, promote, roll back, |
| // manage its hostnames, retire it. |
| Publish Role = "publish" |
| + // Deploy is a deploy pipeline's credential: ship, and promote or roll |
| + // back only the rungs the credential was minted with. It is always bound |
| + // to one app, and it never reaches app lifecycle, retention or domains. |
| + // The rungs live on the credential, not here: this package knows roles, |
| + // not channels (spec 2026-10-05-deploy-credential-design.md §5). |
| + Deploy Role = "deploy" |
| // Instance is the LEGACY app-token authority, defined by observation of |
| // today's call sites rather than by its name — it includes domains and |
| // logs. See the console's role table and spec §5. |
| @@ -49,14 +55,14 @@ const ( |
| // TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly the |
| // doors it reaches today, and this role exists so that pin can hold. |
| Service Role = "service" |
| - // Admin is the union of the four above plus ledgers, store and place. |
| + // Admin is the union of the five above plus ledgers, store and place. |
| // It is NOT "everything harmless": store grant mints a path-scoped IAM |
| // user, which is a real handover. |
| Admin Role = "admin" |
| ) |
| // catalogue is the whole vocabulary, in the order All returns it. |
| -var catalogue = []Role{Publish, Instance, Operate, Service, Admin} |
| +var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin} |
| // All returns the catalogue. The slice is copied so a caller cannot edit the |
| // vocabulary by writing through it. |
diff --git a/internal/svcrole/svcrole_test.go b/internal/svcrole/svcrole_test.go| index 09c5ab0a0..a5775141f 100644 |
| --- a/internal/svcrole/svcrole_test.go |
| +++ b/internal/svcrole/svcrole_test.go |
| @@ -113,3 +113,23 @@ func TestEmptyDistinguishesTheTwoForms(t *testing.T) { |
| t.Error("Allow(Publish).Empty() = true; Empty must not be unconditionally true") |
| } |
| } |
| + |
| +func TestDeployIsMintableAndNarrow(t *testing.T) { |
| + got, ok := Parse("deploy") |
| + if !ok || got != Deploy { |
| + t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok) |
| + } |
| + all := All() |
| + if len(all) < 2 || all[0] != Publish || all[1] != Deploy { |
| + t.Fatalf("All() = %v; want deploy right after publish", all) |
| + } |
| + // Existing doors do not admit it: adding a role must not widen anything. |
| + for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} { |
| + if door.Permits(Deploy) { |
| + t.Fatalf("%v admits deploy", door.Roles()) |
| + } |
| + } |
| + if !Allow(Deploy).Permits(Admin) { |
| + t.Fatal("Allow adds Admin to every set; that changed") |
| + } |
| +} |