-
Notifications
You must be signed in to change notification settings - Fork 12
Hub: add token revocation surface (Store method + RPC + CLI); tokens currently valid forever #95
Description
Summary
The hub has no way to revoke a client token once issued. Tokens minted by Store.RegisterClient (internal/hub/store.go:147) live forever — there's no Revoke, DeleteClient, UnregisterClient, or equivalent on the Store, no corresponding RPC handler in grpc.go, and no CLI command surface. If a token leaks (shared credential file, accidental commit, compromised dev machine), the only mitigation is restarting the hub from a fresh persistence directory — which invalidates every other client's token at the same time.
This was tracked in TASKS.md as part of a broader "prevent duplicate client registration + add token revocation" entry (added 2026年04月08日). The duplicate-registration half is already done; this issue covers only the revocation gap.
What's already in place
Store.RegisterClient rejects duplicate project names (good):
// internal/hub/store.go:147-164 func (s *Store) RegisterClient(client ClientInfo) error { s.mu.Lock() defer s.mu.Unlock() // Reject duplicate project names. for i := range s.clients { if s.clients[i].ProjectName == client.ProjectName { return errHub.DuplicateProject( client.ProjectName, ) } } ... }
No corresponding regression test for the duplicate-rejection path, but that's a small follow-up (see "Out of scope" below).
What's missing
- No
Store.RevokeClient(or equivalent). Grep onRevoke|DeleteClient|UnregisterClient|RemoveClientininternal/hub/returns zero hits. - No RPC method.
internal/hub/grpc.goregisters onlyRegister,Publish, andStatushandlers. - No CLI surface.
ctx hub(or wherever the management commands live) has norevoke/client rm/ similar. - No persistence story. When a token is revoked,
s.clientsands.tokenIdxneed a coordinated update, andclientsPath(s.dir)needs to be rewritten atomically.
Proposed Shape
Three layers, each small:
1. Store.RevokeClient(id string) error
// internal/hub/store.go func (s *Store) RevokeClient(id string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.clients { if s.clients[i].ID != id { continue } delete(s.tokenIdx, s.clients[i].Token) s.clients = append(s.clients[:i], s.clients[i+1:]...) // tokenIdx still points to indices ≥ i; rebuild to keep // them coherent. Cheap because the registry is small. s.tokenIdx = make(map[string]int, len(s.clients)) for j := range s.clients { s.tokenIdx[s.clients[j].Token] = j } return saveJSON(clientsPath(s.dir), s.clients) } return errHub.UnknownClient(id) }
Key by ClientInfo.ID (a stable identifier) rather than ProjectName (which a future operator might rename) or Token (which the operator presumably doesn't have to hand if they're trying to revoke a leaked one).
A sibling RevokeByProject(name string) error is also reasonable since operators usually remember "the customer project that just leaked" before "the client UUID 7f3a..."
2. RPC + handler
Add a Revoke RPC to the admin surface. Should be admin-token-gated (matches how Register handles admin auth in the current handler).
// internal/hub/handler.go type RevokeRequest struct { AdminToken string ClientID string // or ProjectName, see above } type RevokeResponse struct{} func (s *Server) handleRevoke(req *RevokeRequest) (*RevokeResponse, error) { if subtle.ConstantTimeCompare( []byte(req.AdminToken), []byte(s.adminToken), ) != 1 { return nil, status.Error(codes.Unauthenticated, "bad admin token") } if err := s.store.RevokeClient(req.ClientID); err != nil { return nil, err } return &RevokeResponse{}, nil }
Wire via cfgHub.PathRevoke constant + makeRevokeHandler(s) registration in grpc.go.
3. Client wrapper + CLI
Client.Revoke(ctx, adminToken, clientID) error mirrors the existing Client.Register shape. Then a CLI command — ctx hub revoke <client-id> or ctx hub client rm <client-id> — that takes the admin token from $CTX_HUB_ADMIN_TOKEN or a flag.
Tests Required
TestStore_RevokeClient_RemovesByID: register two clients, revoke one by ID, assert the other still validates and the revoked one's token fails ValidateToken.TestStore_RevokeClient_UnknownIDReturnsError: revoke a nonexistent ID, asserterrHub.UnknownClient(or whatever typed error gets defined).TestStore_RevokeClient_PersistsAcrossRestart: revoke, close+reopen the Store from the same directory, assert the revocation survived.TestServer_Revoke_RequiresAdminToken: call the Revoke RPC with a non-admin bearer, assert Unauthenticated.- (Companion)
TestStore_RegisterClient_RejectsDuplicateProject: regression-pin the already-implemented duplicate rejection. Out of scope per "Out of scope" below, but trivially small and worth landing in the same PR if convenient.
Out of Scope
- The
RegisterClientduplicate-rejection itself — already implemented. A test for it would be welcome but doesn't need to block this issue. - Token TTL / scheduled rotation (e.g., tokens expire after 90 days). Different concern; revocation is the prerequisite.
- Audit log of revocations. Possibly worth doing alongside (the structured log surface at
internal/log/eventwould be the natural target), but the minimum useful surface is revocation itself. - Self-service revocation (a client revoking its own token). Admin-only is the simpler v1.
Acceptance
-
Store.RevokeClientlands with tests for happy path, unknown ID, and persistence-across-restart. - Admin-gated Revoke RPC + handler.
- CLI command exposing it.
- Revoked tokens fail
ValidateTokenimmediately (no in-memory staleness).