carlos / platform Public

console: mint, rotate and revoke a service credential

The four owner-gated doors of spec §6, in a new apiservices.go modelled on
fleets.go: create (secret once), list, rotate (secret once), revoke.

All four register with svcrole.Set{} — a credential may never mint, rotate
or revoke a credential, including itself. That is the escalation cut the
design turns on, and cliRoute enforces it before these handlers run, so
nothing in the file re-checks it.

apiOwnerAccount is new: apiPrincipalAccount plus the owner check, the first
owner gate on the API side. Its two refusals differ deliberately — a caller
who does not hold the account gets the family's byte-identical 404, a member
who does gets a 403, because a member has already proved membership and a
404 would send them hunting for a typo in a sqid they typed correctly.

doorsFor walks s.cliRoles, so the door list create hands back is derived from
the same map the gate reads and cannot drift from what the gate allows.

closedDoors grows 8 -> 12 and the deliberate hardcoded count with it; the
{name} wildcard joins the end-to-end walker's replacer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev eaa057f8427f6c1aa94bb1232009ff7781253c6b parent a6a4fe7
5 files changed, +931 −13
  • internal/console/apicli.go +6 −6
  • internal/console/apiservices.go +365 −0
  • internal/console/apiservices_test.go +530 −0
  • internal/console/authcli.go +15 −0
  • internal/console/svcroutes_test.go +15 −7
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 8c29fe390..3d4719ae0 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -280,12 +280,12 @@ func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Ac
// person-only semantics are stated where the handler is, not only where it
// is registered.
//
-// TWO DOORS AND NOT EIGHT, deliberately. The six fleets/* doors are just as
-// person-only and just as closed, and they do NOT call this: adding a second
-// wall to a door no request can reach is ceremony, and ceremony is what gets
-// pattern-matched into the next handler that should not have it. They rely
-// on the registration plus the end-to-end pin
-// (TestEveryClosedDoorRefusesAServiceCredential). These two get the extra
+// TWO DOORS AND NOT TWELVE, deliberately. The six fleets/* doors and the
+// four services/* doors are just as person-only and just as closed, and they
+// do NOT call this: adding a second wall to a door no request can reach is
+// ceremony, and ceremony is what gets pattern-matched into the next handler
+// that should not have it. They rely on the registration plus the end-to-end
+// pin (TestEveryClosedDoorRefusesAServiceCredential). These two get the extra
// line because their handler semantics are person-only in a way a reader
// editing them must not miss — one mints a credential, the other rewrites
// the bounds an app runs inside. Recorded as a choice, not left as an
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
new file mode 100644
index 000000000..a9a179a9e
--- /dev/null
+++ b/internal/console/apiservices.go
@@ -0,0 +1,365 @@
+// The service-credential API (issue #220, spec §6): mint, list, rotate and
+// revoke the principal that is not an address.
+//
+// This is the highest-authority surface the console has. Everything a
+// credential can ever do is decided here, once, at mint — the reach is a
+// property of the credential rather than of whoever holds it next month —
+// so the doors are modelled on fleets.go: the same account resolution, the
+// same mint-once discipline (the plaintext appears in the create and rotate
+// responses and nowhere else, because only its hash is stored), the same
+// byte-identical refusals.
+//
+// Two rules distinguish this file from every other /api/cli/ family:
+//
+// - Every door registers with the ZERO role set, so no service credential
+// reaches any of them — not even an admin one, and not even to rotate
+// itself. That is the escalation cut spec §3 turns on, and it is
+// enforced in cliRoute, before these handlers run. Nothing here
+// re-checks it: one place per rule.
+// - Every door is OWNER-gated (apiOwnerAccount). Minting is an authority
+// grant that outlives its minter and is not the ordinary member write it
+// resembles.
+package console
+
+import (
+ "errors"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// serviceView is the wire shape of one listed credential — deliberately NOT
+// CLIToken, which carries the token hash in its ID field. The listing must
+// never echo a secret and must never echo the stored hash either: the hash
+// is not a secret, but it is the handle a revoke-by-id door addresses rows
+// with, and an API that hands it out invites a caller to build one.
+type serviceView struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ // App is the scope: the empty string means account-scoped, and it is
+ // rendered rather than omitted so "bound to nothing" and "bound to an
+ // app whose name did not survive the wire" cannot be confused.
+ App string `json:"app"`
+ // Created is when the credential was GRANTED; Rotated is when its
+ // current secret began. The two answer different questions — how long
+ // has this existed, and how old is the secret in use right now — and a
+ // rotation policy needs the second without destroying the first
+ // (svcprincipal.go, RotateServicePrincipal).
+ Created string `json:"created"`
+ Rotated string `json:"rotated"`
+ LastUsed string `json:"last_used"`
+ MintedBy string `json:"minted_by"`
+}
+
+// apiOwnerAccount is apiPrincipalAccount plus the owner check, for the doors
+// whose subject is a credential rather than an app.
+//
+// The two refusals are deliberately different and the difference is the
+// point. A caller who does not hold this account at all gets the family's
+// byte-identical 404 from apiPrincipalAccount — unchanged, and the reason it
+// must stay byte-identical is that a distinct answer here would confirm the
+// account exists. A caller who DOES hold it but is only a member gets a 403:
+// they have already proved membership, so there is nothing left to conceal,
+// and a 404 would send an ordinary member hunting for a typo in a sqid they
+// typed correctly.
+//
+// A service principal never arrives here: every door in this file registers
+// with the zero role Set, so the role gate refuses it before the handler
+// runs (spec §3). This gate does not need to re-check that, and deliberately
+// does not — one place per rule. It fails closed anyway if that ever
+// changed: AccountByID, which the service branch resolves through, does not
+// populate Role, so a service's account row could not equal RoleOwner.
+//
+// Account.Role is populated by AccountsFor's join, so the check is one
+// comparison rather than a second query.
+func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
+ p, acct, ok := s.apiPrincipalAccount(w, r)
+ if !ok {
+ return Principal{}, nil, false
+ }
+ if acct.Role != RoleOwner {
+ jsonError(w, http.StatusForbidden, "only an account owner can manage service credentials")
+ return Principal{}, nil, false
+ }
+ return p, acct, true
+}
+
+// doorsFor lists the /api/cli/ patterns a role reaches, sorted.
+//
+// It reads s.cliRoles — the SAME map the gate consults on every request — so
+// the answer create hands back cannot drift from what the credential will
+// actually be admitted to. A hand-written list in a help string would be
+// right on the day it was written and wrong on the day a door moved.
+//
+// This exists because of CLAUDE.md's no-infra rule read as a rule about
+// comprehension rather than only about access: an operator must be able to
+// see what they granted without reading a spec.
+func (s *Server) doorsFor(role svcrole.Role) []string {
+ out := make([]string, 0, len(s.cliRoles))
+ for pattern, set := range s.cliRoles {
+ if set.Permits(role) {
+ out = append(out, pattern)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// handleAPIServiceCreate answers `carlos services create`: mints a
+// credential bound to this account and, optionally, to one app inside it.
+//
+// The plaintext is in this response and nowhere else. Only its sha256 hex
+// reaches the database, so there is no second copy for a later door to hand
+// out and nothing to recover if it is lost — the answer to a lost secret is
+// rotate, which is one command.
+func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.apiOwnerAccount(w, r)
+ if !ok {
+ return
+ }
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ var req struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ App string `json:"app"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ // ValidTunnelName, the grammar a fleet label takes, and for the same
+ // reason: this is a handle an operator types on a command line, and it
+ // is what rotate and revoke address the credential by for the rest of
+ // its life. A name carrying a path separator or a space would reach the
+ // column verbatim with nothing downstream able to fix it.
+ name := strings.TrimSpace(req.Name)
+ if !registry.ValidTunnelName(name) {
+ jsonError(w, http.StatusBadRequest,
+ "invalid credential name — 1-32 characters of a-z, 0-9 and hyphen")
+ return
+ }
+ role, ok := svcrole.Parse(strings.TrimSpace(req.Role))
+ if !ok {
+ roles := svcrole.All()
+ names := make([]string, len(roles))
+ for i, r := range roles {
+ names[i] = string(r)
+ }
+ jsonError(w, http.StatusBadRequest,
+ "unknown role — one of: "+strings.Join(names, ", "))
+ return
+ }
+ // An app-scoped credential must name an app that is actually in this
+ // account. Otherwise the mint succeeds against nothing, and the day
+ // somebody creates an app of that name the credential silently acquires
+ // authority over an app its minter never chose. The 404 is the family's:
+ // the caller already holds this account, so it says only that the app is
+ // not here.
+ app := strings.TrimSpace(req.App)
+ if app != "" {
+ found, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err)
+ jsonError(w, http.StatusInternalServerError, "could not check the app — try again")
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+ }
+ actor := auditActor(p, acct)
+ secret, err := s.store.NewServicePrincipal(acct.ID, name, app, role, actor, time.Now())
+ if err != nil {
+ // The sentinel, not a string match: a name collision is the
+ // caller's to fix and a busy database is not, and the two must not
+ // answer alike.
+ if errors.Is(err, ErrServiceNameTaken) {
+ jsonError(w, http.StatusConflict, "a credential named "+name+" already exists in this account")
+ return
+ }
+ log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again")
+ return
+ }
+ scope := "the whole account"
+ if app != "" {
+ scope = "app " + app
+ }
+ s.audit(acct, name, actor, "service-create",
+ "minted service credential "+name+" with role "+string(role)+" over "+scope+" (cli)")
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "name": name,
+ "role": string(role),
+ "app": app,
+ "account": acct.Sqid,
+ "token": secret,
+ "doors": s.doorsFor(role),
+ })
+}
+
+// handleAPIServiceList answers `carlos services list`: every live credential
+// bound to this account, service-named and legacy-nameless alike, so that
+// absorbing the old kind='app' rows into this model does not make them
+// invisible. A credential nobody can see is one nobody rotates.
+//
+// Never the hash and never a secret — see serviceView.
+func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) {
+ _, acct, ok := s.apiOwnerAccount(w, r)
+ if !ok {
+ return
+ }
+ rows, err := s.store.ServicePrincipals(acct.ID)
+ if err != nil {
+ log.Printf("console: service list %s: %v", acct.Sqid, err)
+ jsonError(w, http.StatusInternalServerError, "could not list credentials")
+ return
+ }
+ out := make([]serviceView, 0, len(rows))
+ for _, t := range rows {
+ out = append(out, serviceView{
+ Name: t.Name,
+ Role: t.Role,
+ App: t.App,
+ Created: serviceStamp(t.CreatedAt),
+ Rotated: serviceStamp(t.RotatedAt),
+ LastUsed: serviceStamp(t.LastUsed),
+ MintedBy: t.Address,
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"services": out})
+}
+
+// serviceStamp renders a timestamp for the listing, with the ZERO time rendered as
+// an empty string rather than as 1 Jan 1970. A credential that has never
+// been used, and a legacy row minted before rotated_at existed, must not
+// read as fifty-six years stale on the page an owner uses to decide what to
+// rotate — that is the same failure CLIToken.NeverExpires exists to prevent
+// at the other end of the row.
+func serviceStamp(t time.Time) string {
+ if t.IsZero() || t.Unix() == 0 {
+ return ""
+ }
+ return t.UTC().Format(time.RFC3339)
+}
+
+// mintedName is the {name} path segment, refused unless it is a name that
+// could have been minted (handleAPIServiceCreate's grammar, applied to the
+// same column from the other side).
+//
+// It costs nothing in behaviour — an unmintable name can never match a live
+// row, so the store would answer "no such credential" anyway — and it buys
+// one thing: an arbitrary path segment, of arbitrary length, never reaches
+// the audit trail's app column on the revoke path, which audits even when
+// nothing matched. Callers turn a false into whatever their door's
+// no-such-credential answer already is, so this narrows no answer and
+// widens none.
+func mintedName(r *http.Request) (string, bool) {
+ // No TrimSpace: create stores the trimmed name, so the stored value
+ // always satisfies this exactly, and a padded segment would not match a
+ // row in any case.
+ name := r.PathValue("name")
+ return name, registry.ValidTunnelName(name)
+}
+
+// handleAPIServiceRotate answers `carlos services rotate`: a fresh secret
+// for a credential that already exists, its hash replacing the stored one.
+//
+// ROTATION KEEPS THE IDENTITY. Same row, same name, same role, same scope,
+// same audit history and same rate-limit budget (principalKey keys a service
+// on its name for exactly this reason) — only the secret moves. What it does
+// do, immediately, is stop the OLD secret working: CLIPrincipal resolves by
+// hash, and the old hash is gone from the table the moment this write lands.
+func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.apiOwnerAccount(w, r)
+ if !ok {
+ return
+ }
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ name, ok := mintedName(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ secret, err := s.store.RotateServicePrincipal(acct.ID, name, time.Now())
+ if err != nil {
+ // Within an account the caller already owns, an unknown name is an
+ // ordinary not-found rather than a cross-tenant probe — the same
+ // reasoning findFleetBox gives for a bad box label inside an owned
+ // fleet. The sentinel is what separates it from a store failure.
+ if errors.Is(err, ErrNoServicePrincipal) {
+ http.NotFound(w, r)
+ return
+ }
+ log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err)
+ jsonError(w, http.StatusInternalServerError, "could not rotate the credential — try again")
+ return
+ }
+ actor := auditActor(p, acct)
+ s.audit(acct, name, actor, "service-rotate", "rotated the secret of service credential "+name+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "name": name,
+ "account": acct.Sqid,
+ "token": secret,
+ })
+}
+
+// handleAPIServiceRevoke answers `carlos services revoke`: the credential
+// stops resolving, at once and for good.
+//
+// REVOCATION IS IDEMPOTENT, and that is a deliberate refusal to be precise.
+// An unknown name, an already-revoked one and a live one all answer the same
+// 200, because the person reaching for this door is usually racing a
+// compromise and must not have to work out whether their first attempt
+// landed. The one thing they need to know afterwards — that the credential
+// is dead — is true in every one of those cases.
+//
+// It costs nothing in disclosure: the caller is the account's owner, so
+// "does a credential of this name exist here" is not a fact being kept from
+// them. It is available from the listing one door away.
+func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.apiOwnerAccount(w, r)
+ if !ok {
+ return
+ }
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ name, ok := mintedName(r)
+ if !ok {
+ // The same answer an unknown name gets, because that is what it is.
+ writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"})
+ return
+ }
+ err := s.store.RevokeServicePrincipal(acct.ID, name, time.Now())
+ if err != nil && !errors.Is(err, ErrNoServicePrincipal) {
+ log.Printf("console: service revoke %s/%s: %v", acct.Sqid, name, err)
+ jsonError(w, http.StatusInternalServerError, "could not revoke the credential — try again")
+ return
+ }
+ // Audited even when nothing matched: "someone tried to revoke X" is
+ // worth having in the trail of an account whose credentials are being
+ // argued about, and the detail says which it was.
+ actor := auditActor(p, acct)
+ detail := "revoked service credential " + name + " (cli)"
+ if err != nil {
+ detail = "revoked service credential " + name + " — nothing live of that name (cli)"
+ }
+ s.audit(acct, name, actor, "service-revoke", detail)
+ // The body carries no name and nothing else request-derived: it is the
+ // same bytes for a live credential, an already-revoked one and a name
+ // that never existed, which is what makes the idempotence above visible
+ // from the outside rather than merely true inside.
+ writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"})
+}
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go
new file mode 100644
index 000000000..57b3ddf89
--- /dev/null
+++ b/internal/console/apiservices_test.go
@@ -0,0 +1,530 @@
+package console
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// mintBody is the create door's request body, spelled once so a test that
+// varies one field says which field it is varying.
+func mintBody(name, role, app string) map[string]string {
+ return map[string]string{"name": name, "role": role, "app": app}
+}
+
+// jsonStrings pulls a []string out of a raw JSON response body. The doors
+// list is the only array this file reads, and reading it through the raw
+// bytes (rather than a decoded map handed down from postJSON) keeps every
+// assertion in this file working from what actually went over the wire.
+func jsonStrings(t *testing.T, body, key string) []string {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode %s: %v (body %s)", key, err, body)
+ }
+ raw, ok := out[key].([]any)
+ if !ok {
+ t.Fatalf("%s is not an array in %s", key, body)
+ }
+ got := make([]string, 0, len(raw))
+ for _, v := range raw {
+ s, ok := v.(string)
+ if !ok {
+ t.Fatalf("%s carries a non-string element in %s", key, body)
+ }
+ got = append(got, s)
+ }
+ return got
+}
+
+// jsonField pulls one string field out of a raw JSON response body.
+func jsonField(t *testing.T, body, key string) string {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode %s: %v (body %s)", key, err, body)
+ }
+ s, _ := out[key].(string)
+ return s
+}
+
+// TestServiceMintIsOwnerOnly — minting is an authority grant that outlives
+// its minter, so it needs the owner gate, not the ordinary member write
+// door. The two refusals differ deliberately: a member who already proved
+// they hold the account gets 403 (there is nothing left to conceal, and a
+// 404 would send them hunting for a typo in a sqid they typed correctly),
+// while a non-member gets the family's byte-identical 404, because a
+// distinct answer would confirm the account exists.
+func TestServiceMintIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-mint-owner")
+ member := cliToken(t, fx, "alice@keymail.dev", "code-svc-mint-member")
+ stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-mint-stranger")
+
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated {
+ t.Errorf("owner mint = %d %s; want 201", code, body)
+ }
+ if code, body := postRaw(t, u, mintBody("other", "publish", ""), member); code != http.StatusForbidden {
+ t.Errorf("member mint = %d %s; want 403", code, body)
+ }
+ strangerCode, strangerBody := postRaw(t, u, mintBody("x", "publish", ""), stranger)
+ if strangerCode != http.StatusNotFound {
+ t.Errorf("non-member mint = %d %s; want 404", strangerCode, strangerBody)
+ }
+ // The same bytes an account that does not exist at all answers with.
+ unknownCode, unknownBody := postRaw(t, fx.ts.URL+"/api/cli/services/zzzz", mintBody("x", "publish", ""), owner)
+ if unknownCode != http.StatusNotFound || unknownBody != strangerBody {
+ t.Errorf("unknown-account mint = %d %q, non-member = %d %q — the 404 is not byte-identical",
+ unknownCode, unknownBody, strangerCode, strangerBody)
+ }
+}
+
+// TestServiceListRotateRevokeAreOwnerOnly: the owner gate belongs on all
+// four doors, not only on the one that hands out a secret. A member who
+// could revoke could take a production path down; a member who could list
+// learns the whole credential inventory.
+func TestServiceListRotateRevokeAreOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ member := cliToken(t, fx, "alice@keymail.dev", "code-svc-doors-member")
+ stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-doors-stranger")
+
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := getRaw(t, base, member); code != http.StatusForbidden {
+ t.Errorf("member list = %d %s; want 403", code, body)
+ }
+ if code, body := getRaw(t, base, stranger); code != http.StatusNotFound {
+ t.Errorf("non-member list = %d %s; want 404", code, body)
+ }
+ for _, verb := range []string{"rotate", "revoke"} {
+ if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, member); code != http.StatusForbidden {
+ t.Errorf("member %s = %d %s; want 403", verb, code, body)
+ }
+ if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, stranger); code != http.StatusNotFound {
+ t.Errorf("non-member %s = %d %s; want 404", verb, code, body)
+ }
+ }
+ // And the member's refusal must be the same at every door — a 403 that
+ // varied by verb would tell a member which of the four they are nearest
+ // to reaching.
+ _, one := postRaw(t, base+"/hub/rotate", map[string]string{}, member)
+ _, two := postRaw(t, base+"/hub/revoke", map[string]string{}, member)
+ if one != two {
+ t.Errorf("member refusal varies by door:\nrotate: %s\nrevoke: %s", one, two)
+ }
+}
+
+// TestAServiceCannotMintAService is the escalation cut, tested from the wire
+// rather than from the role table: a publish credential and an ADMIN one must
+// both meet the 403 the role gate gives a zero Set.
+func TestAServiceCannotMintAService(t *testing.T) {
+ fx := newConsoleFixture(t)
+ for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Admin} {
+ tok := serviceToken(t, fx.st, OpsAccountID, "svc-"+string(role), "", role)
+ for _, path := range []string{
+ "/api/cli/services/" + fx.sqid,
+ "/api/cli/services/" + fx.sqid + "/other/rotate",
+ "/api/cli/services/" + fx.sqid + "/other/revoke",
+ "/api/cli/apps/" + fx.sqid + "/kass/instance-token",
+ } {
+ code, _, body := rawReq(t, http.MethodPost, fx.ts.URL+path, tok, `{}`)
+ if code != http.StatusForbidden {
+ t.Errorf("role %q at POST %s = %d %s; want 403", role, path, code, body)
+ }
+ }
+ // The listing is a door too: an inventory of every credential in
+ // the account is exactly what a compromised one must not enumerate.
+ if code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, tok); code != http.StatusForbidden {
+ t.Errorf("role %q at GET services = %d %s; want 403", role, code, body)
+ }
+ }
+}
+
+// TestMintReturnsTheSecretExactlyOnce — only the hash is stored, so there is
+// no second copy to hand out. The listing must never carry a secret.
+func TestMintReturnsTheSecretExactlyOnce(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-once")
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner)
+ if code != http.StatusCreated {
+ t.Fatalf("mint = %d %s; want 201", code, body)
+ }
+ secret := jsonField(t, body, "token")
+ if secret == "" {
+ t.Fatal("mint returned no token")
+ }
+ // The secret works, which is what makes its absence from the listing
+ // meaningful rather than merely a string not appearing.
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", secret); status != http.StatusOK {
+ t.Fatalf("minted credential at GET apps = %d %s; want 200", status, out)
+ }
+ _, list := getRaw(t, u, owner)
+ if strings.Contains(list, secret) {
+ t.Error("the listing carries the secret")
+ }
+ if strings.Contains(list, hashToken(secret)) {
+ t.Error("the listing carries the stored hash")
+ }
+}
+
+// TestMintRefusesAnUnknownRole keeps the catalogue closed at the door, not
+// just in the type.
+func TestMintRefusesAnUnknownRole(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-role")
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ for _, bad := range []string{"", "root", "owner", "Publish", "*"} {
+ if code, body := postRaw(t, u, mintBody("hub", bad, ""), owner); code != http.StatusBadRequest {
+ t.Errorf("role %q = %d %s; want 400", bad, code, body)
+ }
+ }
+}
+
+// TestMintRefusesAnUnusableName: the name is the handle rotate and revoke
+// address the credential by and the string an operator types on a command
+// line, so it takes the same grammar a fleet label does. A name with a path
+// separator or a leading hyphen in it is refused here rather than becoming a
+// row nobody can address cleanly later.
+func TestMintRefusesAnUnusableName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-name")
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ for _, bad := range []string{"", " ", "Hub", "a/b", "a b", "a.b", strings.Repeat("a", 33)} {
+ if code, body := postRaw(t, u, mintBody(bad, "publish", ""), owner); code != http.StatusBadRequest {
+ t.Errorf("name %q = %d %s; want 400", bad, code, body)
+ }
+ }
+ // A name is trimmed before it is judged, the same as a fleet's.
+ if code, body := postRaw(t, u, mintBody(" hub ", "publish", ""), owner); code != http.StatusCreated {
+ t.Errorf("padded name = %d %s; want 201", code, body)
+ }
+}
+
+// TestMintRefusesAnAppThatIsNotThere: an app-scoped credential minted
+// against a name that is not in the account is authority pointed at
+// nothing — and if that app is later created by someone else in the
+// account, authority pointed at something its minter never chose.
+func TestMintRefusesAnAppThatIsNotThere(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-app")
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := postRaw(t, u, mintBody("router", "instance", "no-such-app"), owner); code != http.StatusNotFound {
+ t.Errorf("mint against a missing app = %d %s; want 404", code, body)
+ }
+ if code, body := postRaw(t, u, mintBody("router", "instance", "kass"), owner); code != http.StatusCreated {
+ t.Errorf("mint against a real app = %d %s; want 201", code, body)
+ }
+}
+
+// TestMintRefusesADuplicateName: within an account a name is the handle, so
+// two live credentials cannot share one. The 409 must come from the
+// sentinel, not from a string match on a driver error, which is what tells
+// it apart from a store that is simply broken.
+func TestMintRefusesADuplicateName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-dup")
+ u := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated {
+ t.Fatalf("first mint = %d %s; want 201", code, body)
+ }
+ if code, body := postRaw(t, u, mintBody("hub", "operate", ""), owner); code != http.StatusConflict {
+ t.Errorf("duplicate mint = %d %s; want 409", code, body)
+ }
+}
+
+// TestCreateAnswersWithTheDoorsItGranted — an operator must be able to see
+// what they gave away without reading a spec (CLAUDE.md's no-infra rule
+// applied to comprehension, not just access). The list is derived from the
+// same map the gate reads, so it cannot drift from what the gate allows.
+func TestCreateAnswersWithTheDoorsItGranted(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-doors")
+ _, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, mintBody("hub", "publish", ""), owner)
+ doors := jsonStrings(t, body, "doors")
+ if len(doors) == 0 {
+ t.Fatal("create returned no door list")
+ }
+ for _, d := range doors {
+ if strings.Contains(d, "instance-token") || strings.Contains(d, "/fleets/") || strings.Contains(d, "/api/cli/services/") {
+ t.Errorf("door list names a closed door: %s", d)
+ }
+ }
+ // It is the gate's own answer, not a hand-written list: every pattern it
+ // names must be a door this server actually admits `publish` to, and
+ // every such door must be named.
+ want := map[string]bool{}
+ for pattern, set := range fx.srv.cliRoles {
+ if set.Permits(svcrole.Publish) {
+ want[pattern] = true
+ }
+ }
+ if len(doors) != len(want) {
+ t.Errorf("door list has %d entries, the gate admits publish to %d", len(doors), len(want))
+ }
+ for _, d := range doors {
+ if !want[d] {
+ t.Errorf("door list names %s, which the gate does not admit publish to", d)
+ }
+ }
+ if !sortedStrings(doors) {
+ t.Errorf("door list is not sorted: %v", doors)
+ }
+}
+
+// sortedStrings reports whether a slice is in ascending order — the door
+// list is sorted so two mints of the same role answer identically, rather
+// than in whatever order a map range happened to produce.
+func sortedStrings(s []string) bool {
+ for i := 1; i < len(s); i++ {
+ if s[i-1] > s[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// TestLegacyAppTokensAppearInTheAccountListing — absorbing them must not hide
+// them. A credential nobody can see is one nobody rotates.
+func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-legacy")
+ if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
+ t.Fatalf("NewAppToken: %v", err)
+ }
+ code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
+ if code != http.StatusOK {
+ t.Fatalf("list = %d %s; want 200", code, body)
+ }
+ if !strings.Contains(body, `"instance"`) || !strings.Contains(body, "titogo") {
+ t.Errorf("legacy app token missing from the account listing: %s", body)
+ }
+}
+
+// TestServiceListRendersTheRotationFacts: the listing exists so an owner can
+// decide what to rotate and what to kill, which takes the scope, the age of
+// the grant, the age of the current secret, when it was last used and who
+// minted it. Asserted as a shape rather than as bytes so the wire may gain
+// fields without this failing.
+func TestServiceListRendersTheRotationFacts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-list-shape")
+ serviceToken(t, fx.st, OpsAccountID, "hub", "kass", svcrole.Operate)
+ code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
+ if code != http.StatusOK {
+ t.Fatalf("list = %d %s; want 200", code, body)
+ }
+ var out struct {
+ Services []struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ App string `json:"app"`
+ Created string `json:"created"`
+ Rotated string `json:"rotated"`
+ LastUsed string `json:"last_used"`
+ MintedBy string `json:"minted_by"`
+ } `json:"services"`
+ }
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode listing: %v (%s)", err, body)
+ }
+ if len(out.Services) != 1 {
+ t.Fatalf("listing = %d rows, want 1: %s", len(out.Services), body)
+ }
+ got := out.Services[0]
+ if got.Name != "hub" || got.Role != "operate" || got.App != "kass" {
+ t.Errorf("row = %+v; want hub/operate/kass", got)
+ }
+ if got.Created == "" || got.Rotated == "" {
+ t.Errorf("row = %+v; created and rotated must both be rendered", got)
+ }
+ if got.MintedBy != "paul@example.test" {
+ t.Errorf("minted_by = %q; want the minter serviceToken recorded", got.MintedBy)
+ }
+ // Never used yet, and an empty string says so rather than the epoch
+ // reading as a real timestamp from 1970.
+ if got.LastUsed != "" {
+ t.Errorf("last_used = %q; want empty for a credential never used", got.LastUsed)
+ }
+}
+
+// TestRotateReplacesTheSecret: rotation is not revocation of the identity —
+// same name, same role, same scope — but the OLD secret must stop working
+// the moment the new one is handed out, or a rotation done because a secret
+// leaked would have changed nothing.
+func TestRotateReplacesTheSecret(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate")
+ old := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusOK {
+ t.Fatalf("the credential does not work before rotation: %d %s", status, body)
+ }
+ code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/hub/rotate", map[string]string{}, owner)
+ if code != http.StatusOK {
+ t.Fatalf("rotate = %d %s; want 200", code, body)
+ }
+ fresh := jsonField(t, body, "token")
+ if fresh == "" || fresh == old {
+ t.Fatalf("rotate returned %q; want a secret that is not the old one", fresh)
+ }
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusUnauthorized {
+ t.Errorf("the OLD secret still works after rotation: %d %s", status, out)
+ }
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", fresh); status != http.StatusOK {
+ t.Errorf("the fresh secret does not work: %d %s", status, out)
+ }
+ // The identity survives: one row, same name and role.
+ _, list := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
+ if !strings.Contains(list, `"hub"`) || !strings.Contains(list, `"publish"`) {
+ t.Errorf("after rotation the listing = %s; want the same name and role", list)
+ }
+}
+
+// TestRotateAnUnknownNameIs404: within an account the caller already owns, an
+// unknown name is an ordinary not-found — not a cross-tenant probe, exactly
+// as findFleetBox reasons about a bad box label inside an owned fleet.
+func TestRotateAnUnknownNameIs404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate-404")
+ if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/nope/rotate",
+ map[string]string{}, owner); code != http.StatusNotFound {
+ t.Errorf("rotate an unknown name = %d %s; want 404", code, body)
+ }
+ // A legacy app token has no name, so an empty-ish name must not reach
+ // it: rotating "the nameless row" would hand out one secret while
+ // invalidating several credentials.
+ if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/%20/rotate",
+ map[string]string{}, owner); code != http.StatusNotFound {
+ t.Errorf("rotate a blank name = %d %s; want 404", code, body)
+ }
+}
+
+// TestRevokeIsIdempotent: revocation must answer the same 200 whether it
+// landed the first time, the second time, or against a name that never
+// existed. The person reaching for it is racing a compromise and must not
+// have to work out whether their first attempt took.
+func TestRevokeIsIdempotent(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke")
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+
+ first, firstBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner)
+ if first != http.StatusOK {
+ t.Fatalf("revoke = %d %s; want 200", first, firstBody)
+ }
+ if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized {
+ t.Errorf("a revoked credential still works: %d %s", status, out)
+ }
+ second, secondBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner)
+ unknown, unknownBody := postRaw(t, base+"/never-existed/revoke", map[string]string{}, owner)
+ // A name that could never have been minted is the same answer again —
+ // refused before it can reach the audit trail, and indistinguishable
+ // from every other revoke from outside.
+ junk, junkBody := postRaw(t, base+"/A%20Name%20Nobody%20Minted/revoke", map[string]string{}, owner)
+ if second != http.StatusOK || unknown != http.StatusOK || junk != http.StatusOK {
+ t.Errorf("re-revoke = %d, unknown revoke = %d, unmintable revoke = %d; want 200 from all three",
+ second, unknown, junk)
+ }
+ if secondBody != firstBody || unknownBody != firstBody || junkBody != firstBody {
+ t.Errorf("revoke answers differ:\nfirst: %s\nsecond: %s\nunknown: %s\njunk: %s",
+ firstBody, secondBody, unknownBody, junkBody)
+ }
+ // And the row leaves the listing rather than lingering as a tombstone
+ // somebody has to interpret.
+ _, list := getRaw(t, base, owner)
+ if strings.Contains(list, `"hub"`) {
+ t.Errorf("a revoked credential is still listed: %s", list)
+ }
+}
+
+// TestServiceDoorsAudit: every one of the three mutations writes an audit
+// row naming the human who did it, because the credential outlives the
+// session that created it and "who granted this" is the first question asked
+// of a credential nobody recognises.
+func TestServiceDoorsAudit(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit")
+ base := fx.ts.URL + "/api/cli/services/" + fx.sqid
+ if code, body := postRaw(t, base, mintBody("hub", "publish", "kass"), owner); code != http.StatusCreated {
+ t.Fatalf("mint = %d %s", code, body)
+ }
+ if code, body := postRaw(t, base+"/hub/rotate", map[string]string{}, owner); code != http.StatusOK {
+ t.Fatalf("rotate = %d %s", code, body)
+ }
+ if code, body := postRaw(t, base+"/hub/revoke", map[string]string{}, owner); code != http.StatusOK {
+ t.Fatalf("revoke = %d %s", code, body)
+ }
+ // Keyed on the credential's own name, the way a fleet's rows are keyed
+ // on the fleet's — the subject of the action, not the app it happens to
+ // be scoped to.
+ events, err := fx.st.Events(OpsAccountID, "hub", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false}
+ for _, e := range events {
+ if _, ok := want[e.Action]; !ok {
+ continue
+ }
+ want[e.Action] = true
+ if e.Address != "paul@keymail.dev" {
+ t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address)
+ }
+ if !strings.Contains(e.Detail, "hub") {
+ t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail)
+ }
+ }
+ for action, seen := range want {
+ if !seen {
+ t.Errorf("no %s audit row", action)
+ }
+ }
+}
+
+// TestDoorsForReadsTheGate: doorsFor is derived from cliRoles, so the answer
+// an operator is given is the answer the gate will give. A closed door
+// appears for no role, and admin sees strictly more than publish.
+func TestDoorsForReadsTheGate(t *testing.T) {
+ s := newConsoleFixture(t).srv
+ publish := s.doorsFor(svcrole.Publish)
+ admin := s.doorsFor(svcrole.Admin)
+ if len(publish) == 0 || len(admin) == 0 {
+ t.Fatalf("doorsFor: publish=%d admin=%d, want both non-empty", len(publish), len(admin))
+ }
+ if len(admin) <= len(publish) {
+ t.Errorf("admin reaches %d doors, publish %d; admin is the union of the catalogue", len(admin), len(publish))
+ }
+ inAdmin := map[string]bool{}
+ for _, d := range admin {
+ inAdmin[d] = true
+ }
+ for _, d := range publish {
+ if !inAdmin[d] {
+ t.Errorf("%s is open to publish but not to admin", d)
+ }
+ }
+ for _, closed := range closedDoors {
+ for _, r := range svcrole.All() {
+ for _, d := range s.doorsFor(r) {
+ if d == closed {
+ t.Errorf("doorsFor(%q) names the closed door %s", r, closed)
+ }
+ }
+ }
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 141ef2f45..36fa8e30a 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -541,6 +541,21 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", svcrole.Set{}, s.handleAPIFleetRotateBox)
s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", svcrole.Set{}, s.handleAPIFleetDetachBox)
s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", svcrole.Set{}, s.handleAPIFleetTagBox)
+ // The service-credential API (apiservices.go, issue #220) — the
+ // principal that is not an address. Every door here takes the ZERO role
+ // set: a credential may never mint, rotate or revoke a credential,
+ // including itself, which is what keeps a publish credential from
+ // quietly becoming an admin one. Owner-gated inside, because minting is
+ // an authority grant that outlives its minter.
+ //
+ // {name} is a wildcard with rotate and revoke as literal segments
+ // beneath it — the mux prefers a literal regardless of registration
+ // order, the same property the fleets and store families already rely
+ // on, so a credential named "rotate" cannot shadow the verb.
+ s.cliRoute("POST /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceCreate)
+ s.cliRoute("GET /api/cli/services/{account}", svcrole.Set{}, s.handleAPIServiceList)
+ s.cliRoute("POST /api/cli/services/{account}/{name}/rotate", svcrole.Set{}, s.handleAPIServiceRotate)
+ s.cliRoute("POST /api/cli/services/{account}/{name}/revoke", svcrole.Set{}, s.handleAPIServiceRevoke)
// Placement lives under the apps family, not fleets: its subject is an
// APP, and a target fleet name is only ever the request body's data.
s.cliRoute("POST /api/cli/apps/{account}/{app}/place", svcrole.Allow(svcrole.Admin), s.handleAPIPlace)
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 5ab5356c9..a3b6ad21b 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -32,13 +32,12 @@ import (
// mintDoors, fleetDoors and policyDoors are the three families no role may
// ever reach. Listed as substrings of the registered pattern, because that is
-// the shape the table is keyed on. /api/cli/services/ has no doors yet — the
-// services API is a later task — so that entry is a standing guard rather
-// than a live assertion: the day those doors appear, they appear closed or
-// this fails.
+// the shape the table is keyed on. /api/cli/services/ was a standing guard
+// before those doors existed; it is a live assertion now that they do, and it
+// covers all four of them (create, list, rotate, revoke) with one entry.
//
// These lists are the INTENT-level pin, and they exist because closedDoors
-// below is only bookkeeping. closedDoors says "these eight are registered
+// below is only bookkeeping. closedDoors says "these twelve are registered
// with the zero Set"; a commit that reopened a door and edited that list in
// the same breath would stay green. These say WHICH DOORS MAY NEVER OPEN,
// whatever the registration currently says, so reopening one takes an edit
@@ -83,6 +82,14 @@ var closedDoors = []string{
"POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate",
"POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach",
"POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags",
+ // Minting, rotating and revoking a SERVICE credential is the same cut
+ // seen from the other side: these are the doors that hand out authority,
+ // so a credential must never reach them — not even the admin one, and
+ // not even to rotate itself. Owner-gated inside, for a person.
+ "POST /api/cli/services/{account}",
+ "GET /api/cli/services/{account}",
+ "POST /api/cli/services/{account}/{name}/rotate",
+ "POST /api/cli/services/{account}/{name}/revoke",
}
// TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the
@@ -575,6 +582,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
"{app}", "kass",
"{fleet}", "edge",
"{label}", "box1",
+ "{name}", "hub",
).Replace(path)
if strings.Contains(u, "{") {
t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern)
@@ -592,8 +600,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 8 {
- t.Errorf("walked %d closed doors, want all %d (and 8 today: instance-token, instances-manifest, six fleets)",
+ if seen != len(closedDoors) || seen != 12 {
+ t.Errorf("walked %d closed doors, want all %d (and 12 today: instance-token, instances-manifest, six fleets, four services)",
seen, len(closedDoors))
}
}