console: stop the ops backfill sweeping grant holders in — report them instead
Authorization is "member of the account AND the account claims the app", so a
membership is access to EVERY app in the account. The backfill swept every
pre-accounts grant holder into ops as a member, on the theory that member is
the harmless role. It is not harmless in ops: ops holds console, platform-site,
status and every unmigrated app. On the flagship that handed a user who held
ONE grant for a toy app the ability to promote and roll back kass, keymail and
the console itself. Nobody granted that; the migration invented it, and undoing
it took hand-edited SQL against a live database.
Now: operators (the flag that IS deployment-wide authority) become ops owners;
everyone else is left out and returned as []GrantHolder{address, apps}, which
NewServer logs as a runbook — who, which apps, and the three commands that give
one of them an account of their own. A count and a list would be a to-do; the
difference decides whether it gets done.
The "promote the earliest grant holder so ops has an owner" fallback is gone
too: it was the same widening by a quieter route, and an ownerless ops is now
recoverable box-side with `console member -add <addr> bab -role owner` — the
tool that did not exist when that fallback was written.
Strictly additive: INSERT OR IGNORE only, no UPDATE, no DELETE. A re-run on the
already-deployed flagship — which ran this once under the old semantics and was
cleaned up by hand — cannot resurrect the removed membership, and cannot
re-role a membership an operator has since created deliberately. Reported
holders exclude anyone already in ops, so the log goes quiet once dealt with.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+243
−103
internal/console/account_test.go+120 −40internal/console/server.go+11 −4internal/console/store.go+112 −59
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 448a54022..8094256e9 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "strings" |
| "testing" |
| "time" |
| @@ -220,76 +221,130 @@ func TestLastAccountRoundTrips(t *testing.T) { |
| } |
| } |
| -// A per-app grant was never deployment-wide authority. ops contains console, |
| -// platform-site and status, so backfilling every grant holder as an OWNER of |
| -// it would hand whoever once shipped one app the right to invite people into |
| -// the account that runs the platform itself. App-grant holders land as |
| -// members; the operator flag — which IS deployment-wide authority — is what |
| -// makes an owner. |
| -func TestBackfillGrantsAreMembersOperatorsAreOwners(t *testing.T) { |
| +// A per-app grant was never deployment-wide authority, and membership IS |
| +// authorization: "member of the account AND the account claims the app" means |
| +// one membership of ops is access to console, platform-site, status and every |
| +// other app that lives there. Sweeping grant holders into ops did exactly that |
| +// on the flagship — someone holding ONE grant for a toy app could promote and |
| +// roll back kass, keymail and the console. Non-operators must stay OUT, and be |
| +// reported so they get accounts of their own. |
| +func TestBackfillLeavesGrantHoldersOutOfOpsAndReportsThem(t *testing.T) { |
| st := openTestStore(t) |
| - if err := st.Grant("dev@keymail.dev", "kass"); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := st.Grant("ops@keymail.dev", "console"); err != nil { |
| - t.Fatal(err) |
| + for _, g := range [][2]string{ |
| + {"dev@keymail.dev", "toy"}, |
| + {"dev@keymail.dev", "another-toy"}, |
| + {"ops@keymail.dev", "console"}, |
| + } { |
| + if err := st.Grant(g[0], g[1]); err != nil { |
| + t.Fatal(err) |
| + } |
| } |
| if err := st.SetOperator("ops@keymail.dev", true); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + |
| + needs, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != RoleMember { |
| - t.Errorf("app-grant holder role = %q, want member", role) |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("app-grant holder got role %q in ops — that is the widening this fixes", role) |
| } |
| if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { |
| t.Errorf("operator role = %q, want owner", role) |
| } |
| - // Both still have their apps — the demotion is of authority, not access. |
| + // Left out is not the same as forgotten: the operator has to be told who |
| + // needs an account, with their apps, or the migration silently loses them. |
| + if len(needs) != 1 || needs[0].Address != "dev@keymail.dev" { |
| + t.Fatalf("report = %+v, want just dev@keymail.dev", needs) |
| + } |
| + if got := strings.Join(needs[0].Apps, ","); got != "another-toy,toy" { |
| + t.Errorf("reported apps = %q, want both, sorted", got) |
| + } |
| + // The app names are still claimed in ops, which is where every |
| + // pre-accounts app lived — migrate-app is what moves them out. |
| apps, _ := st.AppsIn(OpsAccountID) |
| - if len(apps) != 2 { |
| - t.Errorf("AppsIn(ops) = %v, want both apps claimed", apps) |
| + if len(apps) != 3 { |
| + t.Errorf("AppsIn(ops) = %v, want every granted app claimed", apps) |
| } |
| - // Idempotent, and it must not DEMOTE an owner on a later run. |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + |
| + // Idempotent: a re-run changes nothing and reports the same people, so a |
| + // deployment that has already run this (with the OLD semantics, cleaned up |
| + // by hand) neither loses the cleanup nor re-applies the widening. |
| + again, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| + if len(again) != 1 || again[0].Address != "dev@keymail.dev" { |
| + t.Errorf("second report = %+v, want the same", again) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("second run put the grant holder into ops as %q", role) |
| + } |
| if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner { |
| - t.Errorf("second backfill demoted the operator to %q", role) |
| + t.Errorf("second run changed the operator to %q", role) |
| } |
| } |
| -// ops must never end up with nobody who can administer it. With grants but no |
| -// operator, the earliest grant holder is promoted — loudly, because it is a |
| -// fallback, not the rule. |
| -func TestBackfillPromotesAnOwnerWhenThereWouldBeNone(t *testing.T) { |
| +// The flagship shape: the backfill has already run once with the old |
| +// semantics, and the widened membership was removed by hand. Re-running must |
| +// not put it back, and must not touch a membership an operator has since |
| +// created deliberately with `console member`. |
| +func TestBackfillNeverResurrectsARemovedMembership(t *testing.T) { |
| st := openTestStore(t) |
| - base := time.Unix(1700000000, 0) |
| - if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil { |
| + now := time.Now() |
| + if err := st.Grant("dev@keymail.dev", "toy"); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.GrantAt("first@keymail.dev", "a", base); err != nil { |
| + if err := st.Grant("paul@keymail.dev", "kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.BackfillOpsMemberships(now); err != nil { |
| t.Fatal(err) |
| } |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + // An operator deliberately admits somebody as a plain member. |
| + if err := st.AddMember(OpsAccountID, "trusted@keymail.dev", RoleMember, now); err != nil { |
| t.Fatal(err) |
| } |
| - if role, _ := st.Role(OpsAccountID, "first@keymail.dev"); role != RoleOwner { |
| - t.Errorf("earliest grant holder role = %q, want owner (nobody else could administer ops)", role) |
| + |
| + needs, err := st.BackfillOpsMemberships(now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" { |
| + t.Errorf("the removed membership came back as %q", role) |
| + } |
| + if role, _ := st.Role(OpsAccountID, "trusted@keymail.dev"); role != RoleMember { |
| + t.Errorf("deliberate membership changed to %q, want member", role) |
| } |
| - if role, _ := st.Role(OpsAccountID, "second@keymail.dev"); role != RoleMember { |
| - t.Errorf("later grant holder role = %q, want member", role) |
| + // Somebody an operator has already dealt with is not reported again. |
| + for _, h := range needs { |
| + if h.Address == "trusted@keymail.dev" || h.Address == "paul@keymail.dev" { |
| + t.Errorf("reported %s, who is already a member of ops", h.Address) |
| + } |
| } |
| } |
| -// A deployment with no grants at all and no operator gets an ops account with |
| -// no members — which is correct: there is nobody to make an owner, and |
| -// inventing one would be worse than an empty account the first operator |
| -// claims. |
| -func TestBackfillLeavesOpsEmptyWhenThereIsNobody(t *testing.T) { |
| +// With grants but NO operator, nobody is promoted. The old code promoted the |
| +// earliest grant holder to OWNER of ops so the account would have someone who |
| +// could administer it — but ops holds the console itself, and that promotion |
| +// is the same widening by a quieter route. An ownerless ops is recoverable |
| +// box-side (`console member -add <addr> bab -role owner`), which is precisely |
| +// the tool that did not exist when that fallback was written. |
| +func TestBackfillPromotesNobodyWhenThereIsNoOperator(t *testing.T) { |
| st := openTestStore(t) |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + base := time.Unix(1700000000, 0) |
| + if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.GrantAt("first@keymail.dev", "a", base); err != nil { |
| + t.Fatal(err) |
| + } |
| + needs, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| members, err := st.Members(OpsAccountID) |
| @@ -297,7 +352,32 @@ func TestBackfillLeavesOpsEmptyWhenThereIsNobody(t *testing.T) { |
| t.Fatal(err) |
| } |
| if len(members) != 0 { |
| - t.Errorf("Members(ops) = %+v, want none", members) |
| + t.Errorf("Members(ops) = %+v, want nobody — a grant is not authority over ops", members) |
| + } |
| + if len(needs) != 2 { |
| + t.Errorf("report = %+v, want both grant holders", needs) |
| + } |
| +} |
| + |
| +// The advice is the whole point of the report: an operator reading the log |
| +// must be able to copy three commands per person, not go and design a |
| +// migration. It names the apps, so nobody is left behind. |
| +func TestGrantHolderAdviceIsCopyPasteable(t *testing.T) { |
| + msg := grantHolderAdvice([]GrantHolder{ |
| + {Address: "dev@keymail.dev", Apps: []string{"toy", "other"}}, |
| + {Address: "sam@keymail.dev", Apps: []string{"blog"}}, |
| + }) |
| + for _, want := range []string{ |
| + "2 grant holders need accounts", |
| + "dev@keymail.dev", "toy", "other", "sam@keymail.dev", "blog", |
| + "carlos accounts create", "console member -add", "-role owner", "console migrate-app", |
| + } { |
| + if !strings.Contains(msg, want) { |
| + t.Errorf("advice does not mention %q: %s", want, msg) |
| + } |
| + } |
| + if grantHolderAdvice(nil) != "" { |
| + t.Errorf("advice for nobody = %q, want silence", grantHolderAdvice(nil)) |
| } |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 915d50e1d..4b1401bc8 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -165,12 +165,19 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // all of them. Best-effort; a bucket hiccup must not stop the console |
| // booting. |
| materializeQuietly(context.Background(), cfg.Releases, st) |
| - // One-time backfill: every existing per-app grant becomes a membership of |
| - // the ops account, which is where every pre-accounts app already lives. |
| - // Idempotent, and cheap enough to run on every start. |
| - if err := st.BackfillOpsMemberships(time.Now()); err != nil { |
| + // The pre-accounts backfill: operators become owners of ops, every granted |
| + // app name is claimed there, and non-operator grant holders are left out |
| + // and REPORTED — a per-app grant was never authority over ops. Additive and |
| + // idempotent, so it is cheap enough to run on every start. The report is |
| + // logged rather than acted on: only an operator can decide who gets an |
| + // account of their own, but nobody can decide it if nobody is told. |
| + needAccounts, err := st.BackfillOpsMemberships(time.Now()) |
| + if err != nil { |
| return nil, err |
| } |
| + if advice := grantHolderAdvice(needAccounts); advice != "" { |
| + log.Print(advice) |
| + } |
| return s, nil |
| } |
diff --git a/internal/console/store.go b/internal/console/store.go| index ac9869997..e5a994c2d 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -15,7 +15,6 @@ import ( |
| "encoding/base64" |
| "encoding/hex" |
| "fmt" |
| - "log" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -296,84 +295,138 @@ func (s *Store) Owners() (map[string][]string, error) { |
| // ---- accounts, memberships, invites ----------------------------------------- |
| -// BackfillOpsMemberships maps the pre-accounts world onto the new one, once: |
| -// the built-in ops account exists, every address that held ANY app grant is a |
| -// member of it, and every granted app name is claimed in it. That is the |
| -// spec's "every existing grant maps to a membership in the account that owns |
| -// the app" — before accounts, every app was in ops by definition. |
| +// GrantHolder is one pre-accounts app-grant holder that the backfill |
| +// deliberately left OUT of ops, with the apps they held grants for. The |
| +// backfill cannot decide where these people belong — that is an account per |
| +// person or per project, which is an operator's judgement — so it reports them |
| +// instead of guessing. |
| +type GrantHolder struct { |
| + Address string |
| + Apps []string |
| +} |
| + |
| +// BackfillOpsMemberships maps the pre-accounts world onto the new one: the |
| +// built-in ops account exists, every OPERATOR is an owner of it, and every |
| +// granted app name is claimed in it (before accounts, every app was in ops by |
| +// definition). It returns the grant holders it left out, so the caller can say |
| +// who still needs an account of their own. |
| +// |
| +// WHY non-operators are excluded — this widened access in production once |
| +// already, and the reasoning has to survive the next person to read it: |
| // |
| -// Grant holders become MEMBERS, not owners. A per-app grant was never |
| -// deployment-wide authority, and ops is not an ordinary account: it holds |
| -// console, platform-site and status. Backfilling every grant holder as an |
| -// owner would hand whoever once shipped one app the right to invite people |
| -// into the account that runs the platform itself. What IS deployment-wide |
| -// authority is the operator flag, so that — and only that — backfills as |
| -// owner. |
| +// Authorization is "Role(account, address) != "" AND HasAppIn(account, app)". |
| +// A membership is therefore access to EVERY app in the account, not to the app |
| +// somebody once held a grant for. The first version of this function swept all |
| +// grant holders into ops as members, on the theory that a member is the |
| +// harmless role. It is not harmless in ops: ops holds console, platform-site, |
| +// status, and every app that has not yet been migrated out. On the flagship |
| +// that handed a user who held ONE grant — for a toy app — the ability to |
| +// promote and roll back kass, keymail and the console itself. Nobody granted |
| +// them that; the migration invented it, and it took hand-edited SQL to undo. |
| // |
| -// If that would leave ops with no owner at all, the earliest grant holder is |
| -// promoted and the promotion is logged loudly: an account nobody can |
| -// administer is a worse outcome than one operator who has to be told they |
| -// were given the role. A deployment with no grants and no operators gets an |
| -// empty ops account, which is correct — there is nobody to promote, and |
| -// inventing someone would be worse. |
| +// A per-app grant was never deployment-wide authority. What IS deployment-wide |
| +// authority is the operator flag, so that — and only that — makes an ops |
| +// membership here, as owner. |
| // |
| -// Idempotent, so it runs on every console start: existing memberships are |
| -// left exactly as they are (a later run never demotes an owner), and a |
| -// deployment that has already moved on gains nothing and loses nothing. It |
| -// deliberately does NOT delete the owners table: the grants stay readable |
| -// while the migration is being verified. |
| -func (s *Store) BackfillOpsMemberships(now time.Time) error { |
| +// There is deliberately no "promote somebody so ops has an owner" fallback any |
| +// more. The old code promoted the earliest grant holder when no operator |
| +// existed, reasoning that an unadministrable account is worse than an |
| +// unexpected owner. But that is the same widening by a quieter route, and it |
| +// is no longer the only way out: an ownerless ops is recoverable box-side with |
| +// `console member -add <addr> bab -role owner` — the tool that did not exist |
| +// when that fallback was written. |
| +// |
| +// Strictly ADDITIVE and idempotent, so it can run on every console start: it |
| +// only ever INSERT OR IGNOREs, so no existing membership is ever demoted, |
| +// re-roled or removed. That matters concretely — the flagship has already run |
| +// this once under the old semantics and been cleaned up by hand, and a re-run |
| +// must not resurrect what was removed. It also deliberately does NOT delete the |
| +// owners table: the grants stay readable while the migration is verified. |
| +func (s *Store) BackfillOpsMemberships(now time.Time) ([]GrantHolder, error) { |
| tx, err := s.db.Begin() |
| if err != nil { |
| - return err |
| + return nil, err |
| } |
| defer tx.Rollback() |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) |
| VALUES (?, ?, 'ops', ?)`, OpsAccountID, opsSqid, now.Unix()); err != nil { |
| - return err |
| - } |
| - // OR IGNORE, never OR REPLACE: an address that is already a member keeps |
| - // whatever role it has, so a second run cannot demote an owner. |
| - if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at) |
| -SELECT DISTINCT ?, address, ?, ? FROM owners`, OpsAccountID, RoleMember, now.Unix()); err != nil { |
| - return err |
| + return nil, err |
| } |
| + // OR IGNORE, never OR REPLACE or UPDATE: an address that is already a |
| + // member keeps whatever role it has. This is the only membership write in |
| + // the whole function. |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO memberships (account_id, address, role, added_at) |
| -SELECT ?, address, ?, ? FROM users WHERE operator != 0`, OpsAccountID, RoleOwner, now.Unix()); err != nil { |
| - return err |
| - } |
| - // The operators that were already members as grant holders: promote them. |
| - if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? |
| -AND address IN (SELECT address FROM users WHERE operator != 0)`, RoleOwner, OpsAccountID); err != nil { |
| - return err |
| +SELECT ?, address, ?, ? FROM users WHERE operator != 0`, |
| + OpsAccountID, RoleOwner, now.Unix()); err != nil { |
| + return nil, err |
| } |
| + // The app NAMES are still claimed in ops: that is where every pre-accounts |
| + // app physically lives, and claiming them keeps two accounts from minting |
| + // the same name before the migration moves them. `console migrate-app` is |
| + // what re-homes them afterwards. |
| if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at) |
| SELECT app, ?, MIN(address), ? FROM owners GROUP BY app`, OpsAccountID, now.Unix()); err != nil { |
| - return err |
| + return nil, err |
| } |
| - var owners int |
| - if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`, |
| - OpsAccountID, RoleOwner).Scan(&owners); err != nil { |
| - return err |
| + // Who is left over: grant holders who are not operators and hold no ops |
| + // membership. The membership exclusion is what makes the report quiet once |
| + // an operator has dealt with someone — deliberately admitting them, or |
| + // giving them their own account — rather than nagging on every restart. |
| + rows, err := tx.Query(`SELECT o.address, o.app FROM owners o |
| +WHERE o.address NOT IN (SELECT address FROM users WHERE operator != 0) |
| + AND o.address NOT IN (SELECT address FROM memberships WHERE account_id = ?) |
| +ORDER BY o.address, o.app`, OpsAccountID) |
| + if err != nil { |
| + return nil, err |
| } |
| - if owners == 0 { |
| - var first string |
| - err := tx.QueryRow(`SELECT address FROM owners ORDER BY created_at, address LIMIT 1`).Scan(&first) |
| - if err == sql.ErrNoRows { |
| - return tx.Commit() // nobody to promote; an empty ops account is honest |
| + var out []GrantHolder |
| + for rows.Next() { |
| + var addr, app string |
| + if err := rows.Scan(&addr, &app); err != nil { |
| + rows.Close() |
| + return nil, err |
| } |
| - if err != nil { |
| - return err |
| + if n := len(out); n > 0 && out[n-1].Address == addr { |
| + out[n-1].Apps = append(out[n-1].Apps, app) |
| + continue |
| } |
| - if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`, |
| - RoleOwner, OpsAccountID, first); err != nil { |
| - return err |
| + out = append(out, GrantHolder{Address: addr, Apps: []string{app}}) |
| + } |
| + rows.Close() |
| + if err := rows.Err(); err != nil { |
| + return nil, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return nil, err |
| + } |
| + return out, nil |
| +} |
| + |
| +// grantHolderAdvice renders the leftover grant holders as something an |
| +// operator can act on: who, which apps, and the exact three commands that give |
| +// one of them an account. A count and a list would be a to-do; this is a |
| +// runbook, and the difference decides whether it gets done. |
| +// |
| +// Returns "" when there is nobody, so the caller logs nothing rather than a |
| +// line saying nothing happened. |
| +func grantHolderAdvice(holders []GrantHolder) string { |
| + if len(holders) == 0 { |
| + return "" |
| + } |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "console: %d grant holders need accounts of their own — they hold pre-accounts "+ |
| + "app grants and were deliberately NOT made members of ops (a membership there is access to "+ |
| + "console, platform-site and every unmigrated app): ", len(holders)) |
| + for i, h := range holders { |
| + if i > 0 { |
| + b.WriteString("; ") |
| } |
| - log.Printf("console: no operator existed, so %s (the earliest app-grant holder) was made "+ |
| - "OWNER of the ops account — every account needs one. Review this: ops holds console, "+ |
| - "platform-site and status.", first) |
| + fmt.Fprintf(&b, "%s (%s)", h.Address, strings.Join(h.Apps, " ")) |
| } |
| - return tx.Commit() |
| + b.WriteString(". Give each one their own with: carlos accounts create -name <name> " + |
| + "&& console member -add <address> <sqid> -role owner " + |
| + "&& console migrate-app -app <app> -to <sqid>") |
| + return b.String() |
| } |
| // Roles. Two, not a matrix: enough authority to make invites safe, without |