package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/carlosframework/platform/internal/console"
)
// memberUsage is spelled out rather than left to flag.PrintDefaults because
// the ORDER matters to a reader: mode first, then who, then where.
const memberUsage = `usage: console member -add
[-role owner|member]
console member -remove
console member -list `
// runMember is the box-side membership tool — `console grant`'s replacement in
// the accounts world, and the answer to a gap that bit production.
//
// Why it has to exist: authorization is "you are a member of the account AND
// the account claims the app". A membership is therefore access to EVERY app
// in that account, not to one app. When the pre-accounts backfill swept
// per-app grant holders into ops, someone holding a single grant for a toy app
// silently acquired promote/rollback over kass, keymail and the console. The
// only fix available at the time was hand-edited SQL against a live database,
// because grant/revoke still spoke the language of the model that had just
// been replaced. This verb is that missing authority.
//
// Box-side exactly like grant/revoke: it needs no session, no browser and no
// signed-in operator, because it is the tool you reach for when the console's
// own authorization is what is wrong.
func runMember(args []string) {
fs := flag.NewFlagSet("member", flag.ExitOnError)
add := fs.Bool("add", false, "add (or re-role) a member")
remove := fs.Bool("remove", false, "remove a member")
list := fs.Bool("list", false, "list an account's members")
// Defaulting to member, never owner: the safe role is the one you get by
// forgetting the flag. An owner can invite, remove members and delete the
// account, so it is always a deliberate word on the command line.
role := fs.String("role", console.RoleMember, "role for -add: owner or member")
pos := parseInterspersed(fs, args)
modes := 0
for _, on := range []bool{*add, *remove, *list} {
if on {
modes++
}
}
if modes != 1 {
log.Fatal(memberUsage)
}
if *role != console.RoleOwner && *role != console.RoleMember {
log.Fatalf("unknown role %q — there are exactly two: owner or member", *role)
}
var address, sqid string
switch {
case *list:
if len(pos) != 1 {
log.Fatal(memberUsage)
}
sqid = strings.TrimSpace(pos[0])
default:
if len(pos) != 2 {
log.Fatal(memberUsage)
}
typed := pos[0]
sqid = strings.TrimSpace(pos[1])
// Lowercased and trimmed before ANY store call — every other address
// path in the console does the same (sign-in, invite), and the
// memberships primary key is literal text with no COLLATE NOCASE, so
// case is the difference between a row and no row. Two ways an
// unnormalized address here goes wrong, both silent:
//
// 1. `member -add James@Keymail.dev bab` would report "added as
// owner" while writing a row the real session — which signs in as
// james@keymail.dev — can never match. The operator walks away
// believing access was restored when it was not, which is the
// worst possible failure for a tool whose whole job is fixing
// authorization.
// 2. A case-typo'd second owner row would inflate the store's
// last-owner count, letting `member -remove` take away the only
// owner anybody can actually sign in as while the guard reports
// another still exists.
address = strings.ToLower(strings.TrimSpace(typed))
// Same shape check the sign-in form makes: a membership for an address
// keymail could never authenticate is a grant nobody can claim, and a
// silent typo here is a member who "was added" and cannot get in.
if !console.ValidAddress(address) {
log.Fatalf("%q is not a keymail address — expected @, e.g. paul@keymail.dev", typed)
}
}
st := openConsoleDB()
defer st.Close()
// The bucket is the control plane and the console is not its only minter:
// `carlos accounts create` writes the manifest and the registry row without
// ever opening this database. Catch up BEFORE deciding a sqid does not
// exist, or this verb refuses to administer accounts that demonstrably do.
if _, err := console.MaterializeAccounts(context.Background(), optionalStore(), st); err != nil {
// Not fatal: an unreachable bucket must never be the thing that stops
// an operator removing a membership.
log.Printf("warning: could not read accounts from the deployment bucket: %v", err)
}
acct, err := st.AccountBySqid(sqid)
if err != nil {
log.Fatal(err)
}
if acct == nil {
log.Fatalf("no account %q: nothing with that sqid in %s, and no %s in the deployment bucket — "+
// -owner is required there, and naming it here matters: a command
// printed without it fails, and the flag is the whole reason this
// verb is no longer a mandatory follow-up step.
"create one with: carlos accounts create -name -owner ",
sqid, envOr("CONSOLE_DB", "/data/console/console.db"), console.AccountManifestKey(sqid))
}
switch {
case *list:
memberList(st, acct)
case *add:
memberAdd(st, acct, address, *role)
case *remove:
memberRemove(st, acct, address)
}
}
func memberList(st *console.Store, acct *console.Account) {
members, err := st.Members(acct.ID)
if err != nil {
log.Fatal(err)
}
if len(members) == 0 {
// Not an error: an account materialized from the bucket legitimately
// has no members here yet, and saying so is how the operator learns
// this verb is what gives it one.
fmt.Printf("%s (%s) has no members\n", acct.Sqid, acct.Name)
return
}
for _, m := range members {
fmt.Printf("%s\t%s\n", m.Name, m.Role) // Members carries the address in Name
}
}
// memberAdd is idempotent and SAYS which of the three things it did. An
// operator repeating a command has to be able to tell "already done" from
// "just done" — silence reads as success either way, and that is how a
// membership gets added twice with two different roles.
func memberAdd(st *console.Store, acct *console.Account, address, role string) {
current, err := st.Role(acct.ID, address)
if err != nil {
log.Fatal(err)
}
if current == role {
fmt.Printf("%s is already a %s of %s (%s) — nothing to do\n", address, role, acct.Sqid, acct.Name)
return
}
// The last-owner guard lives in the store, inside the transaction that
// does the write — the count and the write cannot be separated by a
// concurrent second demotion. Surfaced here verbatim rather than
// re-checked: two copies of a safety rule is one copy too many.
if err := st.AddMember(acct.ID, address, role, time.Now()); err != nil {
log.Fatal(err)
}
if current == "" {
fmt.Printf("added %s to %s (%s) as %s\n", address, acct.Sqid, acct.Name, role)
return
}
fmt.Printf("changed %s in %s (%s) from %s to %s\n", address, acct.Sqid, acct.Name, current, role)
}
// memberRemove is the verb the flagship needed and did not have. Removing
// somebody who is not a member is a no-op that says so rather than an error:
// the operator's intent — "they must not be in this account" — is already
// satisfied, and failing would make a cleanup script stop halfway.
func memberRemove(st *console.Store, acct *console.Account, address string) {
current, err := st.Role(acct.ID, address)
if err != nil {
log.Fatal(err)
}
if current == "" {
fmt.Printf("%s is not a member of %s (%s) — nothing to do\n", address, acct.Sqid, acct.Name)
return
}
if err := st.RemoveMember(acct.ID, address); err != nil {
log.Fatal(err)
}
fmt.Printf("removed %s (%s) from %s (%s)\n", address, current, acct.Sqid, acct.Name)
}
// parseInterspersed parses flags that may appear AFTER positional arguments.
// Go's flag package stops at the first non-flag word, which would make
// `console member -add alice@keymail.dev bab -role owner` silently ignore the
// role — the one flag whose omission matters most. Re-parsing what is left
// after each positional accepts both orders instead of quietly doing the
// wrong thing.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var pos []string
rest := args
for {
if err := fs.Parse(rest); err != nil {
os.Exit(2) // ExitOnError already printed; unreachable in practice
}
rest = fs.Args()
if len(rest) == 0 {
return pos
}
pos = append(pos, rest[0])
rest = rest[1:]
}
}