carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "flag"
6 "fmt"
7 "log"
8 "os"
9 "strings"
10 "time"
11
12 "github.com/carlosframework/platform/internal/console"
13)
14
15// memberUsage is spelled out rather than left to flag.PrintDefaults because
16// the ORDER matters to a reader: mode first, then who, then where.
17const memberUsage = `usage: console member -add <address> <account-sqid> [-role owner|member]
18 console member -remove <address> <account-sqid>
19 console member -list <account-sqid>`
20
21// runMember is the box-side membership tool — `console grant`'s replacement in
22// the accounts world, and the answer to a gap that bit production.
23//
24// Why it has to exist: authorization is "you are a member of the account AND
25// the account claims the app". A membership is therefore access to EVERY app
26// in that account, not to one app. When the pre-accounts backfill swept
27// per-app grant holders into ops, someone holding a single grant for a toy app
28// silently acquired promote/rollback over kass, keymail and the console. The
29// only fix available at the time was hand-edited SQL against a live database,
30// because grant/revoke still spoke the language of the model that had just
31// been replaced. This verb is that missing authority.
32//
33// Box-side exactly like grant/revoke: it needs no session, no browser and no
34// signed-in operator, because it is the tool you reach for when the console's
35// own authorization is what is wrong.
36func runMember(args []string) {
37 fs := flag.NewFlagSet("member", flag.ExitOnError)
38 add := fs.Bool("add", false, "add (or re-role) a member")
39 remove := fs.Bool("remove", false, "remove a member")
40 list := fs.Bool("list", false, "list an account's members")
41 // Defaulting to member, never owner: the safe role is the one you get by
42 // forgetting the flag. An owner can invite, remove members and delete the
43 // account, so it is always a deliberate word on the command line.
44 role := fs.String("role", console.RoleMember, "role for -add: owner or member")
45 pos := parseInterspersed(fs, args)
46
47 modes := 0
48 for _, on := range []bool{*add, *remove, *list} {
49 if on {
50 modes++
51 }
52 }
53 if modes != 1 {
54 log.Fatal(memberUsage)
55 }
56 if *role != console.RoleOwner && *role != console.RoleMember {
57 log.Fatalf("unknown role %q — there are exactly two: owner or member", *role)
58 }
59
60 var address, sqid string
61 switch {
62 case *list:
63 if len(pos) != 1 {
64 log.Fatal(memberUsage)
65 }
66 sqid = strings.TrimSpace(pos[0])
67 default:
68 if len(pos) != 2 {
69 log.Fatal(memberUsage)
70 }
71 typed := pos[0]
72 sqid = strings.TrimSpace(pos[1])
73 // Lowercased and trimmed before ANY store call — every other address
74 // path in the console does the same (sign-in, invite), and the
75 // memberships primary key is literal text with no COLLATE NOCASE, so
76 // case is the difference between a row and no row. Two ways an
77 // unnormalized address here goes wrong, both silent:
78 //
79 // 1. `member -add James@Keymail.dev bab` would report "added as
80 // owner" while writing a row the real session — which signs in as
81 // james@keymail.dev — can never match. The operator walks away
82 // believing access was restored when it was not, which is the
83 // worst possible failure for a tool whose whole job is fixing
84 // authorization.
85 // 2. A case-typo'd second owner row would inflate the store's
86 // last-owner count, letting `member -remove` take away the only
87 // owner anybody can actually sign in as while the guard reports
88 // another still exists.
89 address = strings.ToLower(strings.TrimSpace(typed))
90 // Same shape check the sign-in form makes: a membership for an address
91 // keymail could never authenticate is a grant nobody can claim, and a
92 // silent typo here is a member who "was added" and cannot get in.
93 if !console.ValidAddress(address) {
94 log.Fatalf("%q is not a keymail address — expected <user>@<host>, e.g. paul@keymail.dev", typed)
95 }
96 }
97
98 st := openConsoleDB()
99 defer st.Close()
100 // The bucket is the control plane and the console is not its only minter:
101 // `carlos accounts create` writes the manifest and the registry row without
102 // ever opening this database. Catch up BEFORE deciding a sqid does not
103 // exist, or this verb refuses to administer accounts that demonstrably do.
104 if _, err := console.MaterializeAccounts(context.Background(), optionalStore(), st); err != nil {
105 // Not fatal: an unreachable bucket must never be the thing that stops
106 // an operator removing a membership.
107 log.Printf("warning: could not read accounts from the deployment bucket: %v", err)
108 }
109
110 acct, err := st.AccountBySqid(sqid)
111 if err != nil {
112 log.Fatal(err)
113 }
114 if acct == nil {
115 log.Fatalf("no account %q: nothing with that sqid in %s, and no %s in the deployment bucket — "+
116 // -owner is required there, and naming it here matters: a command
117 // printed without it fails, and the flag is the whole reason this
118 // verb is no longer a mandatory follow-up step.
119 "create one with: carlos accounts create -name <name> -owner <address>",
120 sqid, envOr("CONSOLE_DB", "/data/console/console.db"), console.AccountManifestKey(sqid))
121 }
122
123 switch {
124 case *list:
125 memberList(st, acct)
126 case *add:
127 memberAdd(st, acct, address, *role)
128 case *remove:
129 memberRemove(st, acct, address)
130 }
131}
132
133func memberList(st *console.Store, acct *console.Account) {
134 members, err := st.Members(acct.ID)
135 if err != nil {
136 log.Fatal(err)
137 }
138 if len(members) == 0 {
139 // Not an error: an account materialized from the bucket legitimately
140 // has no members here yet, and saying so is how the operator learns
141 // this verb is what gives it one.
142 fmt.Printf("%s (%s) has no members\n", acct.Sqid, acct.Name)
143 return
144 }
145 for _, m := range members {
146 fmt.Printf("%s\t%s\n", m.Name, m.Role) // Members carries the address in Name
147 }
148}
149
150// memberAdd is idempotent and SAYS which of the three things it did. An
151// operator repeating a command has to be able to tell "already done" from
152// "just done" — silence reads as success either way, and that is how a
153// membership gets added twice with two different roles.
154func memberAdd(st *console.Store, acct *console.Account, address, role string) {
155 current, err := st.Role(acct.ID, address)
156 if err != nil {
157 log.Fatal(err)
158 }
159 if current == role {
160 fmt.Printf("%s is already a %s of %s (%s) — nothing to do\n", address, role, acct.Sqid, acct.Name)
161 return
162 }
163 // The last-owner guard lives in the store, inside the transaction that
164 // does the write — the count and the write cannot be separated by a
165 // concurrent second demotion. Surfaced here verbatim rather than
166 // re-checked: two copies of a safety rule is one copy too many.
167 if err := st.AddMember(acct.ID, address, role, time.Now()); err != nil {
168 log.Fatal(err)
169 }
170 if current == "" {
171 fmt.Printf("added %s to %s (%s) as %s\n", address, acct.Sqid, acct.Name, role)
172 return
173 }
174 fmt.Printf("changed %s in %s (%s) from %s to %s\n", address, acct.Sqid, acct.Name, current, role)
175}
176
177// memberRemove is the verb the flagship needed and did not have. Removing
178// somebody who is not a member is a no-op that says so rather than an error:
179// the operator's intent — "they must not be in this account" — is already
180// satisfied, and failing would make a cleanup script stop halfway.
181func memberRemove(st *console.Store, acct *console.Account, address string) {
182 current, err := st.Role(acct.ID, address)
183 if err != nil {
184 log.Fatal(err)
185 }
186 if current == "" {
187 fmt.Printf("%s is not a member of %s (%s) — nothing to do\n", address, acct.Sqid, acct.Name)
188 return
189 }
190 if err := st.RemoveMember(acct.ID, address); err != nil {
191 log.Fatal(err)
192 }
193 fmt.Printf("removed %s (%s) from %s (%s)\n", address, current, acct.Sqid, acct.Name)
194}
195
196// parseInterspersed parses flags that may appear AFTER positional arguments.
197// Go's flag package stops at the first non-flag word, which would make
198// `console member -add alice@keymail.dev bab -role owner` silently ignore the
199// role — the one flag whose omission matters most. Re-parsing what is left
200// after each positional accepts both orders instead of quietly doing the
201// wrong thing.
202func parseInterspersed(fs *flag.FlagSet, args []string) []string {
203 var pos []string
204 rest := args
205 for {
206 if err := fs.Parse(rest); err != nil {
207 os.Exit(2) // ExitOnError already printed; unreachable in practice
208 }
209 rest = fs.Args()
210 if len(rest) == 0 {
211 return pos
212 }
213 pos = append(pos, rest[0])
214 rest = rest[1:]
215 }
216}
217