carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Service principals: the console API's non-address caller.
2//
3// A console API token has always resolved to a Keymail address, and an
4// address's authority is the union of its memberships evaluated at request
5// time (apiAccount). That is right for a human at a terminal and wrong for a
6// server holding a credential: the reach grows without the holder acting, and
7// the rate limits are the operator's own. This file is the principal that is
8// not an address.
9//
10// It WIDENS an existing model rather than inventing one. cli_tokens has
11// carried kind='app' — bound to one account and one app, minted by a member,
12// never expiring — since the instances work, and apiInstancePrincipal has
13// gated six doors on it. A legacy row reads back here as an app-scoped
14// service principal holding role `instance`, which is defined by observation
15// of what those tokens reach TODAY (instances, domains, logs — spec §5), not
16// by what the word sounds like.
17//
18// Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §2.
19package console
20
21import (
22 "context"
23 "crypto/subtle"
24 "encoding/json"
25 "errors"
26 "fmt"
27 "net/http"
28 "strconv"
29 "time"
30
31 "github.com/carlosframework/platform/internal/runs"
32 "github.com/carlosframework/platform/internal/svcrole"
33)
34
35// PrincipalKind separates the two things a bearer token can be. It is a typed
36// constant rather than a string compare at each call site so that "is this a
37// person?" cannot be written two subtly different ways in two files.
38type PrincipalKind int
39
40const (
41 PrincipalUser PrincipalKind = iota
42 PrincipalService
43)
44
45// Principal is one resolved caller.
46//
47// Address is the CALLER for a person and the MINTER for a service — a fact
48// recorded so a human stays answerable, never an authority. Nothing in the
49// gate path may read a service's Address as a set of memberships; that
50// mistake is exactly what issue #220 is about.
51type Principal struct {
52 Kind PrincipalKind
53 Address string
54 AccountID int64
55 App string // "" means account-scoped
56 Role svcrole.Role
57 Name string // "" for a person and for legacy kind='app' rows
58 // Rungs is a deploy credential's scope, in ladder order. nil for every
59 // other role, and never empty for a deploy principal: CLIPrincipal
60 // refuses a deploy row it cannot read a scope from.
61 Rungs []string
62 // CredentialID is the credential's stable identity: its cli_tokens
63 // row's random id, kept by rotation and fresh on every mint, so a
64 // revoked name minted again is a different credential. Set for every
65 // service principal; "" for a person.
66 CredentialID string
67 // Commands is a run credential's granted command prefixes, in the
68 // order minted. nil for every other role, and never empty for a run
69 // principal: CLIPrincipal refuses a run row it cannot read them from.
70 Commands [][]string
71}
72
73// ErrServiceNameTaken is returned by NewServicePrincipal when the account
74// already holds a live credential of that name.
75var ErrServiceNameTaken = errors.New("console: a service credential of that name already exists")
76
77// ErrNoServicePrincipal is returned by RotateServicePrincipal,
78// RevokeServicePrincipal and RevokeAccountToken when nothing matched —
79// zero rows affected, not a database failure. A door needs to tell the two
80// apart (404 vs 500) without string-matching an error, which an anonymous
81// errors.New at each call site would force it to do.
82var ErrNoServicePrincipal = errors.New("console: no such credential")
83
84// CLIPrincipal resolves a bearer token to whichever kind of caller minted it.
85// ok is false for unknown, revoked, expired tokens, and — for a kind='service'
86// row — for a role name this binary does not recognise: a caller that reads
87// "unparseable" as "no authority" would still be wrong if this returned a
88// role-less service principal instead of refusing outright, so it refuses.
89func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) {
90 if rawToken == "" {
91 return Principal{}, false
92 }
93 hash := hashToken(rawToken)
94 var storedHash, addr, kind, app, role, name, rungs, credID, commands string
95 var accountID, expires, revoked int64
96 err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs,
97credential_id, commands
98FROM cli_tokens WHERE token_hash = ?`, hash).
99 Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs, &credID, &commands)
100 if err != nil {
101 return Principal{}, false
102 }
103 if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
104 return Principal{}, false
105 }
106 if revoked != 0 {
107 return Principal{}, false
108 }
109 // Expiry, and it is NOT one rule for every kind.
110 //
111 // For an app or service row, expires_at = 0 means never: both minters
112 // write it deliberately (NewAppToken and NewServicePrincipal both
113 // document the trade — a production path that dies on day 91 is worse
114 // than a long-lived credential whose rotation is one command, and
115 // revocation is the control).
116 //
117 // For a PERSON's row, 0 means the Unix epoch, which is to say expired,
118 // exactly as CLITokenAddress has always read it. NewCLIToken always
119 // stamps now+CLITokenTTL so no such row exists today — but this
120 // function is on its way to becoming the ONLY resolver, cliUser
121 // included, and at that moment its reading of 0 becomes the person's
122 // expiry rule. A kind that never expires is a decision each minter
123 // makes; inheriting it by accident would turn any user row that ever
124 // carried 0 into an immortal credential.
125 if kind == cliKindUser {
126 if now.Unix() > expires {
127 return Principal{}, false
128 }
129 } else if !(expires == 0 || now.Unix() <= expires) {
130 return Principal{}, false
131 }
132 var p Principal
133 switch kind {
134 case cliKindUser:
135 // A person carries no role and no scope — nothing else here, ever.
136 p = Principal{Kind: PrincipalUser, Address: addr}
137 case cliKindApp:
138 // The migration guarantee: identical authority to what
139 // apiInstancePrincipal has always granted this row, expressed in the
140 // new vocabulary.
141 p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance,
142 CredentialID: credID}
143 case cliKindService:
144 r, ok := svcrole.Parse(role)
145 if !ok {
146 return Principal{}, false
147 }
148 var scope []string
149 if r == svcrole.Deploy {
150 // Fail closed: a deploy row with no readable scope, or with no
151 // app, is not treated as unscoped (spec §5.5).
152 scope, ok = parseStoredRungs(rungs)
153 if !ok || app == "" {
154 return Principal{}, false
155 }
156 } else if rungs != "" {
157 // Rungs on any other role would read as a limit and enforce
158 // nothing; a row this console did not write does not resolve.
159 return Principal{}, false
160 }
161 var granted [][]string
162 if r == svcrole.Run {
163 // Fail closed, the deploy rule's twin: a run row whose
164 // commands are missing, unreadable, empty or hold a zero-word
165 // prefix is refused, never read as a credential that may run
166 // anything. So is one with no app, or with no credential id,
167 // which is what its runs are recorded against.
168 granted, ok = parseStoredCommands(commands)
169 if !ok || app == "" || credID == "" {
170 return Principal{}, false
171 }
172 } else if commands != "" {
173 // Commands on any other role would read as a limit and
174 // enforce nothing.
175 return Principal{}, false
176 }
177 p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope,
178 CredentialID: credID, Commands: granted}
179 default:
180 return Principal{}, false
181 }
182 // Best-effort: "last used" is for the owner's benefit on the tokens page,
183 // and a failed write must not fail the request it is annotating.
184 s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash)
185 return p, true
186}
187
188// ErrAccountScopedService is a Service credential minted without an app. It is
189// its own sentinel because the mint doors match sentinels rather than strings,
190// and a scope refusal is the caller's to fix while a busy database is not.
191var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app")
192
193// ErrAccountScopedDeploy is a deploy credential minted without an app.
194var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app")
195
196// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is
197// no default set: the minter says what the credential may move.
198var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung")
199
200// ErrRungsNeedDeploy is rungs named for any other role, where they would
201// read as a limit and enforce nothing.
202var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs")
203
204// ErrAccountScopedRun is a run credential minted without an app: the
205// Service rule, for the same reason, with its own sentinel so each door can
206// say which role it is about.
207var ErrAccountScopedRun = errors.New("console: a run credential must be bound to one app")
208
209// ErrRunNeedsCommands is a run credential minted with no command prefixes.
210// There is no default list: the minter says what the credential may run.
211var ErrRunNeedsCommands = errors.New("console: a run credential needs at least one command")
212
213// ErrCommandsNeedRun is commands named for any other role, where they would
214// read as a limit and enforce nothing.
215var ErrCommandsNeedRun = errors.New("console: only a run credential takes commands")
216
217// canonicalCommands is the stored form of a command list: JSON, an array of
218// arrays of strings, in the order given. "" for none.
219func canonicalCommands(prefixes [][]string) (string, error) {
220 if len(prefixes) == 0 {
221 return "", nil
222 }
223 b, err := json.Marshal(prefixes)
224 if err != nil {
225 return "", err
226 }
227 return string(b), nil
228}
229
230// parseStoredCommands reads the column back. Only a list that passes
231// runs.CheckCommandPrefixes, stored in the exact canonical form this
232// console writes, is accepted: anything else is a row this console did not
233// write, and a run credential with an unreadable list must not resolve.
234func parseStoredCommands(s string) ([][]string, bool) {
235 if s == "" {
236 return nil, false
237 }
238 var out [][]string
239 if err := json.Unmarshal([]byte(s), &out); err != nil {
240 return nil, false
241 }
242 if runs.CheckCommandPrefixes(out) != nil {
243 return nil, false
244 }
245 if canon, err := canonicalCommands(out); err != nil || canon != s {
246 return nil, false
247 }
248 return out, true
249}
250
251// NewServicePrincipal mints a credential a SERVER holds, scoped to one
252// account and — optionally — one app within it, and carrying exactly one
253// role from the closed svcrole catalogue. mintedBy is the member who minted
254// it: it goes in `address`, the same field NewAppToken uses for the same
255// reason — the credential speaks for a service, but a human stays
256// answerable for it.
257//
258// name must be non-empty. An empty name is not one a caller chose — it is the value
259// every legacy kind='app' row already carries by default, and both
260// RotateServicePrincipal and RevokeServicePrincipal refuse to match it for
261// exactly that reason (a call with an empty name must never reach a legacy
262// row it did not name). Minting a kind='service' row with an empty name would
263// create a credential neither of those doors could ever address again except
264// by RevokeAccountToken's raw id, so the store refuses it at the door
265// instead of producing an unaddressable row later. Grammar validation for
266// what a GOOD name looks like stays at the console API (Task 6); this is
267// only "not empty".
268//
269// The token never expires (expires_at = 0), the same deliberate trade
270// NewAppToken documents: a production path that silently dies on day 91 is a
271// worse failure than a long-lived credential whose rotation is one command.
272// Revocation is the control.
273//
274// rotated_at is stamped equal to created_at at mint: the current secret
275// began exactly when the credential did. RotateServicePrincipal advances it
276// on every later rotation without touching created_at.
277func (s *Store) claimRawNewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) {
278 return s.NewServicePrincipalScoped(accountID, name, app, role, nil, nil, mintedBy, now)
279}
280
281// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which
282// only a deploy credential takes, and a command list, which only a run
283// credential takes. The rules live here rather than in a handler for the
284// reason the Service rule does: there are two mint doors.
285//
286// Every mint draws a fresh credential_id, including one that reuses a
287// revoked credential's name.
288func (s *Store) claimRawNewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (string, error) {
289 if name == "" {
290 return "", errors.New("console: a service credential needs a name")
291 }
292 if _, ok := svcrole.Parse(string(role)); !ok {
293 return "", fmt.Errorf("console: unknown service role %q", role)
294 }
295 // A Service credential must name one app.
296 //
297 // apiAccountAppBy binds a principal to its app only when p.App != "", so an
298 // account-scoped credential reaches every app in the account — which is
299 // tolerable for a role a person mints for their own tooling and not for one
300 // an APP holds at runtime to spend the deployment's CPU on its own data.
301 //
302 // The check lives here rather than in a handler because there are two mint
303 // doors, the API and the browser form, and the form explicitly offers an
304 // account-wide credential. A handler-level check would leave one of them
305 // open.
306 if role == svcrole.Service && app == "" {
307 return "", ErrAccountScopedService
308 }
309 canon, err := canonicalRungs(rungs)
310 if err != nil {
311 return "", err
312 }
313 if role == svcrole.Deploy {
314 if app == "" {
315 return "", ErrAccountScopedDeploy
316 }
317 if canon == "" {
318 return "", ErrDeployNeedsRungs
319 }
320 } else if canon != "" {
321 return "", ErrRungsNeedDeploy
322 }
323 // The run rules, the deploy rules' twin. Commands and rungs are
324 // separate fields: a run credential takes no rungs (refused above).
325 if role == svcrole.Run {
326 if app == "" {
327 return "", ErrAccountScopedRun
328 }
329 if len(commands) == 0 {
330 return "", ErrRunNeedsCommands
331 }
332 if err := runs.CheckCommandPrefixes(commands); err != nil {
333 return "", err
334 }
335 } else if len(commands) != 0 {
336 return "", ErrCommandsNeedRun
337 }
338 cmds, err := canonicalCommands(commands)
339 if err != nil {
340 return "", err
341 }
342 credID, err := newCredentialID()
343 if err != nil {
344 return "", err
345 }
346 tx, err := s.db.Begin()
347 if err != nil {
348 return "", err
349 }
350 defer tx.Rollback()
351 var count int
352 if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND name = ? AND revoked_at = 0`,
353 cliKindService, accountID, name).Scan(&count); err != nil {
354 return "", err
355 }
356 if count > 0 {
357 return "", ErrServiceNameTaken
358 }
359 raw, err := randomToken()
360 if err != nil {
361 return "", err
362 }
363 if _, err := tx.Exec(`INSERT INTO cli_tokens
364(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs,
365credential_id, commands)
366VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`,
367 hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon,
368 credID, cmds); err != nil {
369 return "", err
370 }
371 if err := tx.Commit(); err != nil {
372 return "", err
373 }
374 return raw, nil
375}
376
377// ServicePrincipals lists one account's live service credentials, newest
378// first: kind='service' rows AND legacy kind='app' rows, normalized by
379// scanCLITokenRows to the instance role they have always held. Absorbing the
380// legacy kind into this model must not make it invisible to the account it
381// is bound to.
382//
383// revoked_at = 0: a revoked row is a tombstone (see RevokeServicePrincipal),
384// not history to display — it stays out of this listing the same way it
385// stays out of CLITokens.
386func (s *Store) ServicePrincipals(accountID int64) ([]CLIToken, error) {
387 rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app,
388role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, commands
389FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0
390ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp)
391 if err != nil {
392 return nil, err
393 }
394 return scanCLITokenRows(rows)
395}
396
397// RotateServicePrincipal mints a fresh secret for a live, NAMED credential
398// and retires the old one in the same statement: same row, same name, same
399// role — only the secret moves, rotated_at advances to now, and last_used
400// resets, because a fresh secret has not been used yet. credential_id and
401// commands are not touched: a rotated credential is the same credential,
402// so it keeps its identity and what it may run. created_at is
403// deliberately left alone: overwriting it would make a credential granted a
404// year ago read as newly minted on the very page an owner uses to decide
405// what is stale. rotated_at is the answer to that question instead — "how
406// old is the secret in use right now" — which is why now is taken as a
407// parameter here rather than read from the clock inline: this write, like
408// every other Store method's, is testable without sleeping. Zero rows
409// affected — including a call with name="" — is ErrNoServicePrincipal, which
410// the door renders as 404.
411//
412// The name != "" guard excludes a legacy kind='app' row, whose name column
413// is always empty: without it, RotateServicePrincipal(acctID, "") would match that
414// row (or, if the account somehow held more than one, all of them) and
415// rotate a credential the caller never named.
416func (s *Store) RotateServicePrincipal(accountID int64, name string, now time.Time) (string, error) {
417 raw, err := randomToken()
418 if err != nil {
419 return "", err
420 }
421 res, err := s.db.Exec(`UPDATE cli_tokens SET token_hash = ?, rotated_at = ?, last_used = 0
422WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`,
423 hashToken(raw), now.Unix(), cliKindService, accountID, name)
424 if err != nil {
425 return "", err
426 }
427 n, err := res.RowsAffected()
428 if err != nil {
429 return "", err
430 }
431 if n == 0 {
432 return "", ErrNoServicePrincipal
433 }
434 return raw, nil
435}
436
437// RevokeServicePrincipal soft-revokes a live, NAMED credential: UPDATE, not
438// DELETE. The row is kept as a TOMBSTONE, not as history for a listing to
439// show — nothing lists a revoked row (ServicePrincipals and CLITokens both
440// filter revoked_at = 0). It is kept for two reasons: so CLIPrincipal goes on
441// refusing this exact secret rather than a gap in the table quietly
442// resolving it again, and so a later NewServicePrincipal minting the same
443// name creates a NEW row rather than resurrecting this one's identity —
444// the unique index (cli_tokens_service_name) is scoped to live rows for
445// exactly that reason. The same name != "" guard applies here, for
446// the same reason RotateServicePrincipal needs it.
447func (s *Store) RevokeServicePrincipal(accountID int64, name string, now time.Time) error {
448 res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ?
449WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`,
450 now.Unix(), cliKindService, accountID, name)
451 if err != nil {
452 return err
453 }
454 n, err := res.RowsAffected()
455 if err != nil {
456 return err
457 }
458 if n == 0 {
459 return ErrNoServicePrincipal
460 }
461 return nil
462}
463
464// RevokeAccountToken soft-revokes one credential by its listing id (the
465// token hash), scoped to the ACCOUNT rather than to the address that minted
466// it — the universal handle: it is the only path that works for every row
467// the account pane lists, service-named or legacy-nameless alike, so that
468// pane needs exactly one Revoke button rather than one per kind.
469// RevokeCLIToken scopes by address instead, which is right for a person
470// killing their own terminal — but a legacy kind='app' row has no name to
471// address by RevokeServicePrincipal, so this is the only handle it has, and
472// an owner revoking a credential a colleague minted needs the account, not
473// the minter's address, as the scope.
474//
475// The broad query (no kind filter) is not a hazard: a person's cli_tokens
476// row always carries account_id = 0 (see CLIToken), so an account-scoped
477// revoke can never reach a human's terminal token — there is no account id
478// for it to match. That is also exactly why accountID == 0 is refused below
479// rather than merely producing a query that happens to match nothing: the
480// same fact that makes the broad query SAFE for every real caller makes a
481// zero-value accountID CATASTROPHIC rather than harmless — a caller that
482// reached this door with an unset or mis-looked-up account id, holding any
483// person's own token_hash as `id` (a listing id, not a secret — see
484// RevokeCLIToken), would revoke that PERSON's terminal token instead of a
485// credential the account actually owns. Refusing it here makes that
486// precondition structural, not a caller obligation.
487func (s *Store) RevokeAccountToken(accountID int64, id string, now time.Time) error {
488 if accountID == 0 {
489 return ErrNoServicePrincipal
490 }
491 res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ?
492WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, accountID)
493 if err != nil {
494 return err
495 }
496 n, err := res.RowsAffected()
497 if err != nil {
498 return err
499 }
500 if n == 0 {
501 return ErrNoServicePrincipal
502 }
503 return nil
504}
505
506// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1)
507
508// principalKey is what a rate limiter counts against.
509//
510// A person keys on their ADDRESS, byte for byte as before — no human's
511// limits move in this change. A NAMED service keys on its NAME, not on its
512// token hash, so that rotating a credential does not hand it a fresh
513// budget: the name is stable across a secret change precisely so budgets
514// and audit rows stay continuous.
515//
516// A legacy kind='app' row carries no name (Name == ""), and mirrors
517// auditActor's own branch for exactly the same row: without this branch,
518// EVERY app token in an account — whichever app it is bound to — would
519// collapse onto the single key "service:<id>/", sharing one budget between
520// credentials that have nothing to do with each other. Concretely: titogo's
521// routers each hold their own app token, several bound to different apps in
522// the same account; a mistyped CNAME on one router's wildFail budget must
523// not 429 every other router's wildcard enable. Keying on App instead of
524// Name gives each of them back its own bucket, the same scope App already
525// carries for authorization.
526//
527// The account id rather than the sqid because this string is never shown to
528// anyone — it is a map key, and the id is what the resolver already has on
529// the Principal without a further lookup.
530func principalKey(p Principal) string {
531 if p.Kind == PrincipalUser {
532 return p.Address
533 }
534 if p.Name == "" {
535 return "app:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App
536 }
537 return "service:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.Name
538}
539
540// allowCreate, allowWrite and allowShip pick the person or the service
541// limiter from the principal, so that no call site has to remember which
542// one it is talking to — a call site that got it wrong would be a server
543// drawing down an operator's budget, which is the bug this whole change is
544// about.
545func (s *Server) allowCreate(p Principal, now time.Time) bool {
546 if p.Kind == PrincipalUser {
547 return s.cli.create.allow(principalKey(p), now)
548 }
549 return s.cli.svcCreate.allow(principalKey(p), now)
550}
551
552// allowTemporaryCreate is /new-temporary's budget: one limiter for both
553// principal kinds, keyed like allowCreate (principalKey), separate from it
554// so neither kind of create spends the other's day.
555func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool {
556 return s.cli.tempCreate.allow(principalKey(p), now)
557}
558
559func (s *Server) allowWrite(p Principal, now time.Time) bool {
560 if p.Kind == PrincipalUser {
561 return s.cli.write.allow(principalKey(p), now)
562 }
563 return s.cli.svcWrite.allow(principalKey(p), now)
564}
565
566// allowShip is the one picker that does NOT key a person on principalKey:
567// the person branch keeps handleAPIShip's existing key, the TOKEN HASH, not
568// the address — a laptop and a CI runner sharing one human's identity share
569// one shipping budget between them otherwise, which is the limit doing less
570// than it should. That reasoning is unchanged by this task; only the
571// service branch is new.
572//
573// It is also the one picker that reports HOW LONG a refusal lasts, because
574// ship is the one budget a legitimate caller meets in a loop (an agent
575// iterating ship-test-ship). The limiter and its key are resolved together
576// and the wait is read from THAT limiter — a caller that picked the wait
577// from the other one would be told a number belonging to a budget it does
578// not spend. The wait is zero, and meaningless, when ok is true.
579func (s *Server) allowShip(p Principal, r *http.Request, now time.Time) (ok bool, wait time.Duration) {
580 lim, key := s.cli.svcShip, principalKey(p)
581 if p.Kind == PrincipalUser {
582 lim, key = s.cli.ship, hashToken(bearer(r))
583 }
584 if lim.allow(key, now) {
585 return true, 0
586 }
587 return false, lim.resetIn(key, now)
588}
589
590// allowWildFail and wildFailTripped are wildFail's own picker pair — the
591// task's premise is that no call site reaches into s.cli by hand to choose
592// a limiter, and wildFail is no exception even though it is a single
593// counter shared by every principal kind rather than a person/service pair
594// like the three above.
595//
596// wildFail's caller needs BOTH a peek and a consume (C33 review): a live,
597// sequential DNS round trip on the request path must not run at all once a
598// principal has already tripped the bound, but only a FAILED lookup may
599// ever add to the count — a caller whose CNAME is correct must never
600// accumulate toward it no matter how many times it (harmlessly) succeeds.
601func (s *Server) allowWildFail(p Principal, now time.Time) bool {
602 return s.cli.wildFail.allow(principalKey(p), now)
603}
604
605func (s *Server) wildFailTripped(p Principal, now time.Time) bool {
606 return s.cli.wildFail.tripped(principalKey(p), now)
607}
608
609// allowDomains is the attach/detach convergence-rate ceiling
610// (domainsCeilingBurst) — not a workload budget (domains doors are exempt
611// from write/instances, spec §4a), a cap on how fast one credential can
612// make every box look at control/serial again. One limiter for every
613// principal kind, the same shape as allowWildFail and for the same reason:
614// the exemption this ceiling sits behind never depended on person vs.
615// service, so the ceiling behind it does not either.
616func (s *Server) allowDomains(p Principal, now time.Time) bool {
617 return s.cli.domainsCeiling.allow(principalKey(p), now)
618}
619func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) {
620 return s.NewServicePrincipalContext(context.Background(), accountID, name, app, role, mintedBy, now)
621}
622func (s *Store) NewServicePrincipalContext(claimContext context.Context, accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) {
623 claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewServicePrincipal", func(claimView *Store) error {
624 claimResult0, claimResult1 = claimView.claimRawNewServicePrincipal(accountID, name, app, role, mintedBy, now)
625 return claimResult1
626 })
627 return
628}
629func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) {
630 return s.NewServicePrincipalScopedContext(context.Background(), accountID, name, app, role, rungs, commands, mintedBy, now)
631}
632func (s *Store) NewServicePrincipalScopedContext(claimContext context.Context, accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) {
633 claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewServicePrincipalScoped", func(claimView *Store) error {
634 claimResult0, claimResult1 = claimView.claimRawNewServicePrincipalScoped(accountID, name, app, role, rungs, commands, mintedBy, now)
635 return claimResult1
636 })
637 return
638}
639