| index ea6d73ea3..ab87e7f02 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -125,6 +125,40 @@ func (s *Store) migrate() error { |
| action TEXT NOT NULL, |
| detail TEXT NOT NULL DEFAULT '', |
| created_at INTEGER NOT NULL |
| +);`, |
| + // ---- multi-account ------------------------------------------------- |
| + // The account is the tenancy primitive: it owns apps and has members. |
| + // id is MINTED (NewAccountID), never autoincrement — see account.go. |
| + // name is deliberately NOT unique: identity is the sqid, so there is |
| + // nothing to squat. |
| + `CREATE TABLE IF NOT EXISTS accounts ( |
| + id INTEGER PRIMARY KEY, |
| + sqid TEXT NOT NULL UNIQUE, |
| + name TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| +);`, |
| + // Membership replaces the per-app owners grant: app access follows |
| + // from account membership. Exactly two roles, owner and member. |
| + `CREATE TABLE IF NOT EXISTS memberships ( |
| + account_id INTEGER NOT NULL, |
| + address TEXT NOT NULL, |
| + role TEXT NOT NULL, |
| + added_at INTEGER NOT NULL, |
| + PRIMARY KEY (account_id, address) |
| +);`, |
| + // Invites are single-use, expiring, revocable. The token is stored in |
| + // the clear (unlike sessions/confirmations): it is delivered into the |
| + // addressee's own Keymail inbox and is worthless without also being |
| + // signed in as that address, and an owner has to be able to LIST and |
| + // revoke a specific pending invite. |
| + `CREATE TABLE IF NOT EXISTS invites ( |
| + token TEXT PRIMARY KEY, |
| + account_id INTEGER NOT NULL, |
| + address TEXT NOT NULL, |
| + invited_by TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + expires_at INTEGER NOT NULL, |
| + accepted_at INTEGER NOT NULL DEFAULT 0 |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| @@ -135,12 +169,66 @@ func (s *Store) migrate() error { |
| // re-running is always safe. |
| for _, stmt := range []string{ |
| `ALTER TABLE users ADD COLUMN operator INTEGER NOT NULL DEFAULT 0;`, |
| + // Where a returning user lands: the account they were last in. |
| + `ALTER TABLE users ADD COLUMN last_account INTEGER NOT NULL DEFAULT 0;`, |
| + // Apps belong to an account. The column lands with default 1 (ops), so |
| + // every already-claimed app keeps working; the PRIMARY KEY stays `app` |
| + // until the migration verb rebuilds the table, because changing a PK |
| + // is not an additive migration. |
| + `ALTER TABLE apps ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| } |
| } |
| - return nil |
| + return s.migrateAppsKey() |
| +} |
| + |
| +// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name |
| +// land-grab — to PRIMARY KEY(account_id, app), which is what "app names are |
| +// unique within an account, never globally" actually means. SQLite cannot |
| +// ALTER a primary key, so this is the one place in the console that rebuilds |
| +// a table rather than appending to the ALTER list. |
| +// |
| +// It is safe to do here rather than behind an operator verb because every row |
| +// is carried across unchanged (account_id already defaulted to 1 = ops), the |
| +// table is small, and the whole thing runs in one transaction. Leaving it to |
| +// a verb would mean a console that silently refuses a second account's "blog" |
| +// until someone remembers to run it. |
| +// |
| +// Idempotent: it does nothing once the new key is in place. |
| +func (s *Store) migrateAppsKey() error { |
| + var ddl string |
| + err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='apps'`).Scan(&ddl) |
| + if err != nil { |
| + return err |
| + } |
| + if strings.Contains(ddl, "PRIMARY KEY (account_id, app)") { |
| + return nil // already rebuilt |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + for _, stmt := range []string{ |
| + `CREATE TABLE apps_new ( |
| + app TEXT NOT NULL, |
| + account_id INTEGER NOT NULL DEFAULT 1, |
| + created_by TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (account_id, app) |
| +);`, |
| + `INSERT INTO apps_new (app, account_id, created_by, created_at) |
| + SELECT app, account_id, created_by, created_at FROM apps;`, |
| + `DROP TABLE apps;`, |
| + `ALTER TABLE apps_new RENAME TO apps;`, |
| + } { |
| + if _, err := tx.Exec(stmt); err != nil { |
| + return err |
| + } |
| + } |
| + return tx.Commit() |
| } |
| |
| // ---- owners ----------------------------------------------------------------- |
| @@ -194,13 +282,358 @@ func (s *Store) Owners() (map[string][]string, error) { |
| return out, rows.Err() |
| } |
| |
| +// ---- accounts, memberships, invites ----------------------------------------- |
| + |
| +// Roles. Two, not a matrix: enough authority to make invites safe, without |
| +// inventing a permissions model before there is a second user to test it |
| +// against. |
| +const ( |
| + RoleOwner = "owner" |
| + RoleMember = "member" |
| +) |
| + |
| +// InviteTTL bounds a pending invite. |
| +const InviteTTL = 7 * 24 * time.Hour |
| + |
| +// Account is one tenancy root as the console sees it. Role is filled only by |
| +// AccountsFor (it is the viewer's role, not a property of the account). |
| +type Account struct { |
| + ID int64 |
| + Sqid string |
| + Name string |
| + Role string |
| + CreatedAt time.Time |
| +} |
| + |
| +// Invite is one pending invitation. |
| +type Invite struct { |
| + Token string |
| + AccountID int64 |
| + Address string |
| + InvitedBy string |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| +} |
| + |
| +// CreateAccount records a minted account and makes its creator the first |
| +// owner, in ONE transaction: an account with no owner is unadministrable — |
| +// nobody left who can invite, rename, or delete it — so the two writes must |
| +// never be separable by a crash. |
| +func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now time.Time) error { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + if _, err := tx.Exec(`INSERT INTO accounts (id, sqid, name, created_at) VALUES (?, ?, ?, ?)`, |
| + id, sqid, name, now.Unix()); err != nil { |
| + return err |
| + } |
| + if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at) VALUES (?, ?, ?, ?)`, |
| + id, ownerAddress, RoleOwner, now.Unix()); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// AccountBySqid returns the account with that public identity, or (nil, nil) |
| +// when there is none — "no such account" is an answer, not an error, and the |
| +// caller renders it as the same 404 a nonexistent page gets. |
| +func (s *Store) AccountBySqid(sqid string) (*Account, error) { |
| + var a Account |
| + var created int64 |
| + err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE sqid = ?`, sqid). |
| + Scan(&a.ID, &a.Sqid, &a.Name, &created) |
| + if err == sql.ErrNoRows { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + a.CreatedAt = time.Unix(created, 0).UTC() |
| + return &a, nil |
| +} |
| + |
| +// AccountByID is AccountBySqid keyed the other way, for the MRU landing. |
| +func (s *Store) AccountByID(id int64) (*Account, error) { |
| + var a Account |
| + var created int64 |
| + err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE id = ?`, id). |
| + Scan(&a.ID, &a.Sqid, &a.Name, &created) |
| + if err == sql.ErrNoRows { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + a.CreatedAt = time.Unix(created, 0).UTC() |
| + return &a, nil |
| +} |
| + |
| +// AccountsFor returns the accounts address is a member of, in creation order |
| +// — the switcher's list, and the "your first membership" fallback when there |
| +// is no MRU history. Ordered by created_at then id so two accounts created in |
| +// the same second still order deterministically. |
| +func (s *Store) AccountsFor(address string) ([]Account, error) { |
| + rows, err := s.db.Query(`SELECT a.id, a.sqid, a.name, a.created_at, m.role |
| +FROM accounts a JOIN memberships m ON m.account_id = a.id |
| +WHERE m.address = ? ORDER BY a.created_at, a.id`, address) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Account |
| + for rows.Next() { |
| + var a Account |
| + var created int64 |
| + if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created, &a.Role); err != nil { |
| + return nil, err |
| + } |
| + a.CreatedAt = time.Unix(created, 0).UTC() |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// Role is address's role in the account, "" when they are not a member. It is |
| +// the one permission check every account-scoped route makes. |
| +func (s *Store) Role(accountID int64, address string) (string, error) { |
| + var role string |
| + err := s.db.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`, |
| + accountID, address).Scan(&role) |
| + if err == sql.ErrNoRows { |
| + return "", nil |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + return role, nil |
| +} |
| + |
| +// Members lists an account's memberships, owners first then by address. |
| +func (s *Store) Members(accountID int64) ([]Account, error) { |
| + rows, err := s.db.Query(`SELECT address, role FROM memberships WHERE account_id = ? |
| +ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, address`, accountID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Account |
| + for rows.Next() { |
| + var a Account |
| + if err := rows.Scan(&a.Name, &a.Role); err != nil { // Name carries the address here |
| + return nil, err |
| + } |
| + a.ID = accountID |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// AddMember adds or re-roles a member. Demoting the LAST owner is refused for |
| +// the same reason removing them is: it leaves an account nobody can |
| +// administer. Transfer ownership first. |
| +func (s *Store) AddMember(accountID int64, address, role string, now time.Time) error { |
| + if role != RoleOwner && role != RoleMember { |
| + return fmt.Errorf("console: invalid role %q", role) |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + if role != RoleOwner { |
| + if err := lastOwnerGuard(tx, accountID, address); err != nil { |
| + return err |
| + } |
| + } |
| + if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at) |
| +VALUES (?, ?, ?, ?) |
| +ON CONFLICT(account_id, address) DO UPDATE SET role = excluded.role`, |
| + accountID, address, role, now.Unix()); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// RemoveMember drops a membership, refusing to remove the last owner. |
| +func (s *Store) RemoveMember(accountID int64, address string) error { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + if err := lastOwnerGuard(tx, accountID, address); err != nil { |
| + return err |
| + } |
| + if _, err := tx.Exec(`DELETE FROM memberships WHERE account_id = ? AND address = ?`, |
| + accountID, address); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// lastOwnerGuard refuses a change that would leave an account with no owner. |
| +// Inside the caller's transaction, so the count and the write cannot be |
| +// separated by a concurrent second removal. |
| +func lastOwnerGuard(tx *sql.Tx, accountID int64, address string) error { |
| + var role string |
| + err := tx.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`, |
| + accountID, address).Scan(&role) |
| + if err == sql.ErrNoRows { |
| + return nil // not a member: nothing to protect |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if role != RoleOwner { |
| + return nil |
| + } |
| + var owners int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM memberships WHERE account_id = ? AND role = ?`, |
| + accountID, RoleOwner).Scan(&owners); err != nil { |
| + return err |
| + } |
| + if owners <= 1 { |
| + return fmt.Errorf("console: %s is the last owner of this account — transfer ownership first", address) |
| + } |
| + return nil |
| +} |
| + |
| +// SetLastAccount records where a user was last, so signing in lands them |
| +// back there. Upserts the users row: an invited address that has never |
| +// signed in still gets a landing spot. |
| +func (s *Store) SetLastAccount(address string, accountID int64) error { |
| + _, err := s.db.Exec(`INSERT INTO users (address, created_at, last_account) VALUES (?, ?, ?) |
| +ON CONFLICT(address) DO UPDATE SET last_account = excluded.last_account`, |
| + address, time.Now().Unix(), accountID) |
| + return err |
| +} |
| + |
| +// LastAccount is the account id a user was last in, 0 when there is no |
| +// history (a fresh user, or one whose only account was deleted). |
| +func (s *Store) LastAccount(address string) (int64, error) { |
| + var id int64 |
| + err := s.db.QueryRow(`SELECT last_account FROM users WHERE address = ?`, address).Scan(&id) |
| + if err == sql.ErrNoRows { |
| + return 0, nil |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + return id, nil |
| +} |
| + |
| +// CreateInvite mints a single-use, 7-day invitation for address, returning |
| +// the token the sealed Keymail link carries. |
| +func (s *Store) CreateInvite(accountID int64, address, invitedBy string, now time.Time) (string, error) { |
| + tok, err := randomToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + _, err = s.db.Exec(`INSERT INTO invites (token, account_id, address, invited_by, created_at, expires_at) |
| +VALUES (?, ?, ?, ?, ?, ?)`, |
| + tok, accountID, address, invitedBy, now.Unix(), now.Add(InviteTTL).Unix()) |
| + if err != nil { |
| + return "", err |
| + } |
| + return tok, nil |
| +} |
| + |
| +// AcceptInvite consumes an invite and creates the membership, in one |
| +// transaction. Three gates, all inside it: the token must be unaccepted, the |
| +// address must be the one it was addressed to (an invite is not a bearer |
| +// token — the link lands in ONE keymail inbox and only that identity can use |
| +// it), and it must not have expired. |
| +func (s *Store) AcceptInvite(token, address string, now time.Time) (int64, error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + var accountID, expires, accepted int64 |
| + var want string |
| + err = tx.QueryRow(`SELECT account_id, address, expires_at, accepted_at FROM invites WHERE token = ?`, |
| + token).Scan(&accountID, &want, &expires, &accepted) |
| + if err == sql.ErrNoRows { |
| + return 0, fmt.Errorf("console: no such invite") |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + if accepted != 0 { |
| + return 0, fmt.Errorf("console: that invite has already been used") |
| + } |
| + if want != address { |
| + return 0, fmt.Errorf("console: that invite was sent to a different address") |
| + } |
| + if now.Unix() > expires { |
| + return 0, fmt.Errorf("console: that invite has expired") |
| + } |
| + if _, err := tx.Exec(`UPDATE invites SET accepted_at = ? WHERE token = ?`, now.Unix(), token); err != nil { |
| + return 0, err |
| + } |
| + // An invite always grants member; owners are promoted deliberately. |
| + if _, err := tx.Exec(`INSERT INTO memberships (account_id, address, role, added_at) |
| +VALUES (?, ?, ?, ?) ON CONFLICT(account_id, address) DO NOTHING`, |
| + accountID, address, RoleMember, now.Unix()); err != nil { |
| + return 0, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return 0, err |
| + } |
| + return accountID, nil |
| +} |
| + |
| +// RevokeInvite deletes a pending invite. Deleting rather than flagging is |
| +// deliberate: a revoked invite must be indistinguishable from one that never |
| +// existed, so a leaked link reveals nothing about the account. |
| +func (s *Store) RevokeInvite(token string) error { |
| + _, err := s.db.Exec(`DELETE FROM invites WHERE token = ?`, token) |
| + return err |
| +} |
| + |
| +// PendingInvites lists an account's unaccepted, unexpired invitations. |
| +func (s *Store) PendingInvites(accountID int64) ([]Invite, error) { |
| + rows, err := s.db.Query(`SELECT token, account_id, address, invited_by, created_at, expires_at |
| +FROM invites WHERE account_id = ? AND accepted_at = 0 ORDER BY created_at, address`, accountID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Invite |
| + for rows.Next() { |
| + var i Invite |
| + var created, expires int64 |
| + if err := rows.Scan(&i.Token, &i.AccountID, &i.Address, &i.InvitedBy, &created, &expires); err != nil { |
| + return nil, err |
| + } |
| + i.CreatedAt = time.Unix(created, 0).UTC() |
| + i.ExpiresAt = time.Unix(expires, 0).UTC() |
| + out = append(out, i) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // ---- apps (claim-once names) ------------------------------------------------ |
| |
| -// CreateApp claims an app name, once. created is false when the name was |
| -// already claimed — the caller treats that exactly like every other "taken". |
| +// OpsAccountID is the built-in operator account's id, pinned by registry |
| +// migration on every deployment (its sqid is release.OpsAccount). |
| +const OpsAccountID = 1 |
| + |
| +// CreateApp claims an app name in the built-in ops account — the shape the |
| +// account-less /apps routes still use. CreateAppIn is the account-aware verb. |
| func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, err error) { |
| - res, err := s.db.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?) |
| -ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix()) |
| + return s.CreateAppIn(OpsAccountID, app, createdBy, now) |
| +} |
| + |
| +// CreateAppIn claims an app name WITHIN an account, once. created is false |
| +// when that account already has the name — another account holding it is not |
| +// a conflict, because app names are unique per account, never globally. |
| +func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) { |
| + res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at) |
| +VALUES (?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| + app, accountID, createdBy, now.Unix()) |
| if err != nil { |
| return false, err |
| } |
| @@ -208,6 +641,39 @@ ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix()) |
| return n > 0, err |
| } |
| |
| +// AppsIn lists an account's claimed app names, sorted. |
| +func (s *Store) AppsIn(accountID int64) ([]string, error) { |
| + rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? ORDER BY app`, accountID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []string |
| + for rows.Next() { |
| + var a string |
| + if err := rows.Scan(&a); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// HasAppIn reports whether an account already claims an app name. |
| +func (s *Store) HasAppIn(accountID int64, app string) (bool, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, |
| + accountID, app).Scan(&n) |
| + return n > 0, err |
| +} |
| + |
| +// SetAppAccount re-homes a claimed app onto another account — the console |
| +// half of `carlos accounts migrate`. |
| +func (s *Store) SetAppAccount(app string, accountID int64) error { |
| + _, err := s.db.Exec(`UPDATE apps SET account_id = ? WHERE app = ?`, accountID, app) |
| + return err |
| +} |
| + |
| // HasApp reports whether an app name is claimed in the apps table. |
| func (s *Store) HasApp(app string) (bool, error) { |
| var n int |