Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Hub: add token revocation surface (Store method + RPC + CLI); tokens currently valid forever #95

Open
Labels
bugSomething isn't working enhancementNew feature or request

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 on Revoke|DeleteClient|UnregisterClient|RemoveClient in internal/hub/ returns zero hits.
  • No RPC method. internal/hub/grpc.go registers only Register, Publish, and Status handlers.
  • No CLI surface. ctx hub (or wherever the management commands live) has no revoke / client rm / similar.
  • No persistence story. When a token is revoked, s.clients and s.tokenIdx need a coordinated update, and clientsPath(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, assert errHub.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 RegisterClient duplicate-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/event would 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.RevokeClient lands with tests for happy path, unknown ID, and persistence-across-restart.
  • Admin-gated Revoke RPC + handler.
  • CLI command exposing it.
  • Revoked tokens fail ValidateToken immediately (no in-memory staleness).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

        AltStyle によって変換されたページ (->オリジナル) /