diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index c0bff465b..f3cfb0e7b 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -382,6 +382,7 @@ hub: status Show cluster status peer Add or remove cluster peers stepdown Transfer leadership to another node + revoke Revoke a client token See `ctx hub --help` for details. For client-side setup (register, subscribe, sync, listen, publish), see @@ -429,6 +430,18 @@ hub.stepdown: before the current leader steps down. Use before taking a node offline for maintenance. short: Transfer leadership +hub.revoke: + long: |- + Revoke a client's token, invalidating it immediately. + + Identify the client by its ID (as shown when it was + registered). The revoked token stops authenticating on the + next request; other clients are unaffected. + + Requires the hub admin token, supplied via --token or the + CTX_HUB_ADMIN_TOKEN environment variable. The hub address is + read from the saved connection config. + short: Revoke a client token hook: long: |- Manage hook-related settings: messages, notifications, diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index 7303a3af9..197d70a1a 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -291,6 +291,8 @@ hub.start.port: short: Hub listen port (default 9900) hub.stop.data-dir: short: Hub data directory (default ~/.ctx/hub-data/) +hub.revoke.token: + short: Admin credential from hub startup (or $CTX_HUB_ADMIN_TOKEN) watch.dry-run: short: Show updates without applying watch.log: diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 716615774..19db4fa7b 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -647,6 +647,10 @@ err.hub.internal: short: 'internal: %w' err.hub.duplicate-project: short: 'project already registered: %q' +err.hub.unknown-client: + short: 'unknown client: %q' +err.hub.admin-token-required: + short: 'admin token required: pass --token or set $CTX_HUB_ADMIN_TOKEN' err.hub.invalid-peer-action: short: "action must be 'add' or 'remove', got %q" err.serve.no-running-hub: diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 76ff970cc..8fddf3947 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -1048,6 +1048,8 @@ write.hub-added-peer: short: 'Added peer %s' write.hub-removed-peer: short: 'Removed peer %s' +write.hub-revoked: + short: 'Revoked client %s' write.hub-leadership-transferred: short: Leadership transferred write.hub-leader: diff --git a/internal/cli/hub/cmd/revoke/cmd.go b/internal/cli/hub/cmd/revoke/cmd.go new file mode 100644 index 000000000..73adb51eb --- /dev/null +++ b/internal/cli/hub/cmd/revoke/cmd.go @@ -0,0 +1,66 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package revoke + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + coreRevoke "github.com/ActiveMemory/ctx/internal/cli/hub/core/revoke" + "github.com/ActiveMemory/ctx/internal/config/cli" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + "github.com/ActiveMemory/ctx/internal/config/env" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + errHub "github.com/ActiveMemory/ctx/internal/err/hub" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the hub revoke subcommand. +// +// Returns: +// - *cobra.Command: The revoke subcommand +func Cmd() *cobra.Command { + var adminToken string + + short, long := desc.Command(cmd.DescKeyHubRevoke) + + c := &cobra.Command{ + Use: cmd.UseHubRevoke, + Short: short, + Long: long, + Example: desc.Example(cmd.DescKeyHubRevoke), + Args: cobra.ExactArgs(1), + // Hub stores at ~/.ctx/hub-data/, not .context/. + // Spec: specs/single-source-context-anchor.md. + Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue}, + RunE: func( + cobraCmd *cobra.Command, args []string, + ) error { + // Admin token: --token flag takes precedence, then + // the CTX_HUB_ADMIN_TOKEN environment variable. + token := adminToken + if token == "" { + token = os.Getenv(env.HubAdminToken) + } + if token == "" { + cobraCmd.SilenceUsage = true + return errHub.AdminTokenRequired() + } + return coreRevoke.Run(cobraCmd, args[0], token) + }, + } + + flagbind.StringFlag( + c, &adminToken, + cFlag.Token, flag.DescKeyHubRevokeToken, + ) + + return c +} diff --git a/internal/cli/hub/cmd/revoke/doc.go b/internal/cli/hub/cmd/revoke/doc.go new file mode 100644 index 000000000..3e14185d6 --- /dev/null +++ b/internal/cli/hub/cmd/revoke/doc.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package revoke wires the ctx hub revoke subcommand. +// +// # Overview +// +// [Cmd] builds the cobra command that revokes a client's token +// on the hub. It takes the target client ID as its single +// positional argument and the admin token from the --token flag +// or the CTX_HUB_ADMIN_TOKEN environment variable, then +// delegates to the core revoke package. +// +// # Behavior +// +// The command resolves the admin token (flag first, then +// environment) and fails early with a clear error if neither is +// set. Like the other hub subcommands it skips context init, +// since the hub lives at ~/.ctx/hub-data/ rather than .context/. +package revoke diff --git a/internal/cli/hub/core/revoke/doc.go b/internal/cli/hub/core/revoke/doc.go new file mode 100644 index 000000000..571b53862 --- /dev/null +++ b/internal/cli/hub/core/revoke/doc.go @@ -0,0 +1,36 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package revoke implements client token revocation for the +// ctx hub revoke command. +// +// # Overview +// +// This package revokes a registered client's token on a remote +// hub, invalidating it immediately. It is the operator-side +// counterpart to registration: where registration mints a +// token, revocation destroys one. +// +// # Behavior +// +// [Run] dials the hub via gRPC and calls the admin-gated Revoke +// RPC. Authentication uses the hub admin token (resolved from +// the --token flag or the CTX_HUB_ADMIN_TOKEN environment +// variable by the command layer), not the stored bearer token. +// +// # Data Flow +// +// When [Run] is called it performs these steps: +// +// 1. Loads connection config to obtain the hub address +// (same source as ctx hub status). +// 2. Dials the hub via gRPC using hub.NewClient with no +// bearer token; the Revoke RPC is admin-gated instead. +// 3. Calls the Revoke RPC with the admin token and the +// target client ID. +// 4. On success, delegates to writeHub.Revoked to confirm +// the revocation to the user. +package revoke diff --git a/internal/cli/hub/core/revoke/revoke.go b/internal/cli/hub/core/revoke/revoke.go new file mode 100644 index 000000000..0cc6bc44f --- /dev/null +++ b/internal/cli/hub/core/revoke/revoke.go @@ -0,0 +1,64 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package revoke + +import ( + "context" + + "github.com/spf13/cobra" + + connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" + "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" + writeHub "github.com/ActiveMemory/ctx/internal/write/hub" +) + +// Run revokes a client's token on the hub. +// +// The hub address is read from the saved connection config +// (same as `ctx hub status`). Authentication uses the admin +// token, not the stored bearer token, so a fresh client is +// dialed without one. +// +// Parameters: +// - cmd: cobra command for output +// - clientID: ID of the client to revoke +// - adminToken: hub admin token (already resolved from flag +// or environment by the caller) +// +// Returns: +// - error: non-nil if config load, dial, or revocation fails +func Run( + cmd *cobra.Command, + clientID string, + adminToken string, +) error { + cfg, loadErr := connectCfg.Load() + if loadErr != nil { + return loadErr + } + + client, dialErr := hub.NewClient(cfg.HubAddr, "") + if dialErr != nil { + return dialErr + } + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() + + if revErr := client.Revoke( + context.Background(), adminToken, clientID, + ); revErr != nil { + return revErr + } + + writeHub.Revoked(cmd, clientID) + return nil +} diff --git a/internal/cli/hub/hub.go b/internal/cli/hub/hub.go index 1a6aa2973..a8c3635db 100644 --- a/internal/cli/hub/hub.go +++ b/internal/cli/hub/hub.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/peer" + "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/revoke" "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/start" hubStatus "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/status" "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/stepdown" @@ -22,7 +23,7 @@ import ( // // Returns: // - *cobra.Command: hub with start, stop, status, peer, -// stepdown +// stepdown, revoke func Cmd() *cobra.Command { return parent.Cmd( cmd.DescKeyHub, cmd.UseHub, @@ -31,5 +32,6 @@ func Cmd() *cobra.Command { hubStatus.Cmd(), peer.Cmd(), stepdown.Cmd(), + revoke.Cmd(), ) } diff --git a/internal/config/embed/cmd/hub.go b/internal/config/embed/cmd/hub.go index 60199bbcf..b0891111f 100644 --- a/internal/config/embed/cmd/hub.go +++ b/internal/config/embed/cmd/hub.go @@ -20,6 +20,8 @@ const ( UseHubPeer = "peer
" // UseHubStepdown is the Use string for hub stepdown. UseHubStepdown = "stepdown" + // UseHubRevoke is the Use string for hub revoke. + UseHubRevoke = "revoke " // DescKeyHub is the desc key for the hub command. DescKeyHub = "hub" @@ -33,4 +35,6 @@ const ( DescKeyHubPeer = "hub.peer" // DescKeyHubStepdown is the desc key for hub stepdown. DescKeyHubStepdown = "hub.stepdown" + // DescKeyHubRevoke is the desc key for hub revoke. + DescKeyHubRevoke = "hub.revoke" ) diff --git a/internal/config/embed/flag/hub.go b/internal/config/embed/flag/hub.go index 9b5079768..2dfd54ddd 100644 --- a/internal/config/embed/flag/hub.go +++ b/internal/config/embed/flag/hub.go @@ -18,4 +18,6 @@ const ( DescKeyHubStartPeers = "hub.start.peers" // DescKeyHubStopDataDir is the text key for hub stop --data-dir. DescKeyHubStopDataDir = "hub.stop.data-dir" + // DescKeyHubRevokeToken is the text key for hub revoke --token. + DescKeyHubRevokeToken = "hub.revoke.token" ) diff --git a/internal/config/embed/text/err_hub.go b/internal/config/embed/text/err_hub.go index c22588c83..bd0b6ef4a 100644 --- a/internal/config/embed/text/err_hub.go +++ b/internal/config/embed/text/err_hub.go @@ -17,6 +17,12 @@ const ( // DescKeyErrHubDuplicateProject is the text key for // duplicate project registration errors. DescKeyErrHubDuplicateProject = "err.hub.duplicate-project" + // DescKeyErrHubUnknownClient is the text key for a + // revocation targeting a client ID that is not registered. + DescKeyErrHubUnknownClient = "err.hub.unknown-client" + // DescKeyErrHubAdminTokenRequired is the text key for an + // admin-gated command invoked with no admin token supplied. + DescKeyErrHubAdminTokenRequired = "err.hub.admin-token-required" // DescKeyErrHubInvalidPeerAction is the text key for // unrecognized peer action errors. DescKeyErrHubInvalidPeerAction = "err.hub.invalid-peer-action" diff --git a/internal/config/embed/text/write_hub.go b/internal/config/embed/text/write_hub.go index cef973e7e..2654d7be3 100644 --- a/internal/config/embed/text/write_hub.go +++ b/internal/config/embed/text/write_hub.go @@ -26,4 +26,7 @@ const ( // DescKeyWriteHubClusterStats is the text key for hub // cluster statistics. DescKeyWriteHubClusterStats = "write.hub-cluster-stats" + // DescKeyWriteHubRevoked is the text key for the hub client + // revocation confirmation. + DescKeyWriteHubRevoked = "write.hub-revoked" ) diff --git a/internal/config/env/env.go b/internal/config/env/env.go index b19ccc391..fad81a46b 100644 --- a/internal/config/env/env.go +++ b/internal/config/env/env.go @@ -24,6 +24,11 @@ const ( // SkipPathCheck is the environment variable that skips the PATH // validation during init. Set to True in tests. SkipPathCheck = "CTX_SKIP_PATH_CHECK" + // HubAdminToken is the environment variable holding the hub + // admin token, used as a fallback when --token is not passed + // to admin-gated commands like `ctx hub revoke`. + //nolint:gosec // G101: env var name, not a credential + HubAdminToken = "CTX_HUB_ADMIN_TOKEN" ) // Environment toggle values. diff --git a/internal/config/hub/hub.go b/internal/config/hub/hub.go index 0d8fe4e70..a42a2b96c 100644 --- a/internal/config/hub/hub.go +++ b/internal/config/hub/hub.go @@ -27,6 +27,8 @@ const ( MethodListen = "Listen" // MethodStatus is the Status RPC method name. MethodStatus = "Status" + // MethodRevoke is the Revoke RPC method name. + MethodRevoke = "Revoke" ) // Full gRPC method paths (ServicePath + MethodName). @@ -41,6 +43,8 @@ const ( PathListen = ServicePath + MethodListen // PathStatus is the full gRPC path for Status. PathStatus = ServicePath + MethodStatus + // PathRevoke is the full gRPC path for Revoke. + PathRevoke = ServicePath + MethodRevoke ) // Authorization header. @@ -195,6 +199,9 @@ const ( ErrInvalidAdminToken = "invalid admin token" // ErrProjectNameRequired is the gRPC error for missing project name. ErrProjectNameRequired = "project_name required" + // ErrClientIDRequired is the gRPC error for a missing client ID + // on the Revoke RPC. + ErrClientIDRequired = "client_id required" // ErrMissingMetadata is the gRPC error for missing metadata. ErrMissingMetadata = "missing metadata" // ErrMissingToken is the gRPC error for missing auth token. diff --git a/internal/err/hub/hub.go b/internal/err/hub/hub.go index 222e16033..361dd2281 100644 --- a/internal/err/hub/hub.go +++ b/internal/err/hub/hub.go @@ -7,6 +7,7 @@ package hub import ( + "errors" "fmt" "github.com/ActiveMemory/ctx/internal/assets/read/desc" @@ -53,6 +54,33 @@ func DuplicateProject(name string) error { ) } +// UnknownClient returns an error when no registered client +// matches the given ID (e.g. an operator tries to revoke a +// client that was never registered or was already revoked). +// +// Parameters: +// - id: the client ID that could not be found +// +// Returns: +// - error: "unknown client: " +func UnknownClient(id string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrHubUnknownClient), id, + ) +} + +// AdminTokenRequired returns an error when an admin-gated +// command is run without an admin token supplied via either the +// --token flag or the CTX_HUB_ADMIN_TOKEN environment variable. +// +// Returns: +// - error: guidance on how to supply the admin token +func AdminTokenRequired() error { + return errors.New( + desc.Text(text.DescKeyErrHubAdminTokenRequired), + ) +} + // InvalidPeerAction returns an error for an unrecognized // peer action. // diff --git a/internal/hub/client.go b/internal/hub/client.go index 1cdb1af01..6e204257f 100644 --- a/internal/hub/client.go +++ b/internal/hub/client.go @@ -69,6 +69,33 @@ func (c *Client) Register( return resp, callErr } +// Revoke calls the Revoke RPC with the admin token, +// invalidating the given client's token on the hub. +// +// Parameters: +// - ctx: context for the call +// - adminToken: admin token from hub startup +// - clientID: ID of the client to revoke +// +// Returns: +// - error: non-nil if auth fails or the client is unknown +func (c *Client) Revoke( + ctx context.Context, + adminToken string, + clientID string, +) error { + resp := &RevokeResponse{} + return c.conn.Invoke( + ctx, + cfgHub.PathRevoke, + &RevokeRequest{ + AdminToken: adminToken, + ClientID: clientID, + }, + resp, + ) +} + // Publish calls the Publish RPC. // // Parameters: diff --git a/internal/hub/grpc.go b/internal/hub/grpc.go index e7ef1ee8e..c8437944a 100644 --- a/internal/hub/grpc.go +++ b/internal/hub/grpc.go @@ -54,6 +54,10 @@ func serviceDesc(s *Server) *grpc.ServiceDesc { MethodName: cfgHub.MethodStatus, Handler: makeStatusHandler(s), }, + { + MethodName: cfgHub.MethodRevoke, + Handler: makeRevokeHandler(s), + }, }, Streams: []grpc.StreamDesc{ { @@ -102,6 +106,28 @@ func makeRegisterHandler(s *Server) grpc.MethodHandler { } } +// makeRevokeHandler creates the Revoke handler. +// Revoke uses admin token auth, not bearer (matches Register). +// +// Parameters: +// - s: hub server for request dispatch +// +// Returns: +// - grpc.MethodHandler: unary handler for Revoke RPC +func makeRevokeHandler(s *Server) grpc.MethodHandler { + return func( + _ any, ctx context.Context, + dec func(any) error, + _ grpc.UnaryServerInterceptor, + ) (any, error) { + req := &RevokeRequest{} + if decErr := dec(req); decErr != nil { + return nil, decErr + } + return s.revoke(ctx, req) + } +} + // makePublishHandler creates the Publish handler. // // Parameters: diff --git a/internal/hub/handler.go b/internal/hub/handler.go index edbc95f30..5857596f0 100644 --- a/internal/hub/handler.go +++ b/internal/hub/handler.go @@ -67,6 +67,45 @@ func (s *Server) register( }, nil } +// revoke handles the Revoke RPC. +// +// Admin-token-gated (mirrors register): only the operator +// holding the admin token may revoke a client. On success the +// client's token stops validating immediately. +// +// Parameters: +// - ctx: request context (unused) +// - req: revoke request with admin token and client ID +// +// Returns: +// - *RevokeResponse: empty on success +// - error: PermissionDenied on bad admin token, InvalidArgument +// on empty client ID, NotFound on unknown client +func (s *Server) revoke( + _ context.Context, req *RevokeRequest, +) (*RevokeResponse, error) { + if req.AdminToken != s.adminToken { + return nil, status.Error( + codes.PermissionDenied, + cfgHub.ErrInvalidAdminToken, + ) + } + if req.ClientID == "" { + return nil, status.Error( + codes.InvalidArgument, + cfgHub.ErrClientIDRequired, + ) + } + + if revErr := s.store.RevokeClient(req.ClientID); revErr != nil { + return nil, status.Error( + codes.NotFound, revErr.Error(), + ) + } + + return &RevokeResponse{}, nil +} + // publish handles the Publish RPC. // // Parameters: diff --git a/internal/hub/persist.go b/internal/hub/persist.go index 9351c2287..f29539a11 100644 --- a/internal/hub/persist.go +++ b/internal/hub/persist.go @@ -92,6 +92,28 @@ func saveJSON(path string, src any) error { return io.SafeWriteFile(path, data, fs.PermFile) } +// saveJSONAtomic marshals src and writes it to path atomically +// (temp file + fsync + rename), so a crash or partial write can +// never leave a truncated file in place. Used for destructive +// full-file rewrites like client revocation, where a torn write +// would corrupt the entire registry rather than a single record. +// +// Parameters: +// - path: file path to write +// - src: value to marshal as JSON +// +// Returns: +// - error: non-nil if marshal or write fails +func saveJSONAtomic(path string, src any) error { + data, marshalErr := json.MarshalIndent( + src, "", cfgHub.JSONIndent, + ) + if marshalErr != nil { + return marshalErr + } + return io.SafeWriteFileAtomic(path, data, fs.PermFile) +} + // appendFile appends data to a file, creating it if needed. // // Parameters: diff --git a/internal/hub/server_test.go b/internal/hub/server_test.go index bf762f458..42501e0b1 100644 --- a/internal/hub/server_test.go +++ b/internal/hub/server_test.go @@ -122,6 +122,79 @@ func TestServerRegisterBadToken(t *testing.T) { } } +func TestServer_Revoke_RequiresAdminToken(t *testing.T) { + _, conn, adminTok := startTestServer(t) + + reg := callRegister(t, conn, adminTok, "alpha") + + // Revoke with a non-admin token must be rejected. + resp := &RevokeResponse{} + err := conn.Invoke( + context.Background(), + "/ctx.hub.v1.CtxHub/Revoke", + &RevokeRequest{ + AdminToken: "not-the-admin-token", + ClientID: reg.ClientID, + }, + resp, + ) + if err == nil { + t.Fatal("expected error revoking with non-admin token") + } +} + +func TestServer_Revoke_RevokedTokenFailsValidate(t *testing.T) { + _, conn, adminTok := startTestServer(t) + + reg := callRegister(t, conn, adminTok, "alpha") + ctx := authedCtx(reg.ClientToken) + + // The client can publish while its token is valid. + pubResp := &PublishResponse{} + if pubErr := conn.Invoke(ctx, + "/ctx.hub.v1.CtxHub/Publish", + &PublishRequest{Entries: []PublishEntry{{ + ID: "e1", + Type: "decision", + Content: "Use Go", + Origin: "alpha", + Timestamp: time.Now().Unix(), + }}}, + pubResp, + ); pubErr != nil { + t.Fatalf("Publish before revoke: %v", pubErr) + } + + // Revoke the client (admin-gated). + revResp := &RevokeResponse{} + if revErr := conn.Invoke( + context.Background(), + "/ctx.hub.v1.CtxHub/Revoke", + &RevokeRequest{ + AdminToken: adminTok, + ClientID: reg.ClientID, + }, + revResp, + ); revErr != nil { + t.Fatalf("Revoke: %v", revErr) + } + + // The revoked token must stop authenticating immediately. + if pubErr := conn.Invoke(ctx, + "/ctx.hub.v1.CtxHub/Publish", + &PublishRequest{Entries: []PublishEntry{{ + ID: "e2", + Type: "decision", + Content: "Should fail", + Origin: "alpha", + Timestamp: time.Now().Unix(), + }}}, + &PublishResponse{}, + ); pubErr == nil { + t.Fatal("expected publish with revoked token to fail") + } +} + func TestServerPublishAndSync(t *testing.T) { _, conn, adminTok := startTestServer(t) diff --git a/internal/hub/store.go b/internal/hub/store.go index 3c3504b76..acb68ed6e 100644 --- a/internal/hub/store.go +++ b/internal/hub/store.go @@ -163,6 +163,45 @@ func (s *Store) RegisterClient(client ClientInfo) error { return saveJSON(clientsPath(s.dir), s.clients) } +// RevokeClient removes a client from the registry by ID, +// invalidating its token immediately. +// +// The client is keyed by ID (a stable identifier) rather than +// ProjectName (which an operator might rename) or Token (which +// the operator revoking a leaked credential may not have to +// hand). On success the token is dropped from tokenIdx, the +// client is spliced out of the registry, tokenIdx is rebuilt to +// keep its indices coherent with the shrunk slice, and the +// registry file is rewritten atomically. +// +// Parameters: +// - id: ID of the client to revoke +// +// Returns: +// - error: errHub.UnknownClient if no client has the given +// ID, or a persistence error if the rewrite fails +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:]...) + // The splice shifts every element after i down by one, + // so tokenIdx still points at stale indices. Rebuild it. + // 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 saveJSONAtomic(clientsPath(s.dir), s.clients) + } + return errHub.UnknownClient(id) +} + // ValidateToken checks if a token matches a registered // client using constant-time comparison. // diff --git a/internal/hub/store_test.go b/internal/hub/store_test.go index a79d01503..63f81ea62 100644 --- a/internal/hub/store_test.go +++ b/internal/hub/store_test.go @@ -209,6 +209,127 @@ func TestStoreValidateToken_RejectsNearMissTokens(t *testing.T) { } } +func TestStore_RevokeClient_RemovesByID(t *testing.T) { + dir := t.TempDir() + s, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + + if regErr := s.RegisterClient(ClientInfo{ + ID: "c1", ProjectName: "alpha", Token: "tok_alpha", + }); regErr != nil { + t.Fatal(regErr) + } + if regErr := s.RegisterClient(ClientInfo{ + ID: "c2", ProjectName: "beta", Token: "tok_beta", + }); regErr != nil { + t.Fatal(regErr) + } + + if revErr := s.RevokeClient("c1"); revErr != nil { + t.Fatalf("RevokeClient: %v", revErr) + } + + // Revoked client's token must no longer validate. + if s.ValidateToken("tok_alpha") != nil { + t.Error("revoked token should not validate") + } + // The surviving client must still validate, and the + // rebuilt tokenIdx must resolve it to the right record. + survivor := s.ValidateToken("tok_beta") + if survivor == nil { + t.Fatal("non-revoked client should still validate") + } + if survivor.ProjectName != "beta" { + t.Errorf("wrong survivor project: %q", survivor.ProjectName) + } +} + +func TestStore_RevokeClient_UnknownIDReturnsError(t *testing.T) { + dir := t.TempDir() + s, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + + if regErr := s.RegisterClient(ClientInfo{ + ID: "c1", ProjectName: "alpha", Token: "tok_alpha", + }); regErr != nil { + t.Fatal(regErr) + } + + if revErr := s.RevokeClient("does-not-exist"); revErr == nil { + t.Fatal("expected error revoking unknown client ID") + } + + // The registry must be untouched by a failed revoke. + if s.ValidateToken("tok_alpha") == nil { + t.Error("existing client should be unaffected by a failed revoke") + } +} + +func TestStore_RevokeClient_PersistsAcrossRestart(t *testing.T) { + dir := t.TempDir() + + s1, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + if regErr := s1.RegisterClient(ClientInfo{ + ID: "c1", ProjectName: "alpha", Token: "tok_alpha", + }); regErr != nil { + t.Fatal(regErr) + } + if regErr := s1.RegisterClient(ClientInfo{ + ID: "c2", ProjectName: "beta", Token: "tok_beta", + }); regErr != nil { + t.Fatal(regErr) + } + if revErr := s1.RevokeClient("c1"); revErr != nil { + t.Fatal(revErr) + } + + // Reopen from the same directory: the revocation must survive. + s2, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + if s2.ValidateToken("tok_alpha") != nil { + t.Error("revocation should persist across restart") + } + if s2.ValidateToken("tok_beta") == nil { + t.Error("non-revoked client should persist across restart") + } +} + +func TestStore_RegisterClient_RejectsDuplicateProject(t *testing.T) { + dir := t.TempDir() + s, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + + if regErr := s.RegisterClient(ClientInfo{ + ID: "c1", ProjectName: "alpha", Token: "tok_1", + }); regErr != nil { + t.Fatal(regErr) + } + + // A second client with the same project name must be rejected. + dupErr := s.RegisterClient(ClientInfo{ + ID: "c2", ProjectName: "alpha", Token: "tok_2", + }) + if dupErr == nil { + t.Fatal("expected duplicate project registration to be rejected") + } + + // The rejected client's token must not have been indexed. + if s.ValidateToken("tok_2") != nil { + t.Error("rejected duplicate should not be registered") + } +} + func TestStoreStats(t *testing.T) { dir := t.TempDir() s, err := NewStore(dir) diff --git a/internal/hub/types.go b/internal/hub/types.go index d18736cf3..ab5530e1d 100644 --- a/internal/hub/types.go +++ b/internal/hub/types.go @@ -172,6 +172,20 @@ type RegisterResponse struct { ClientToken string `json:"client_token"` } +// RevokeRequest is the input for the Revoke RPC. +// +// Fields: +// - AdminToken: admin token from server startup +// - ClientID: ID of the client whose token to revoke +type RevokeRequest struct { + AdminToken string `json:"admin_token"` + ClientID string `json:"client_id"` +} + +// RevokeResponse is the output of the Revoke RPC. It carries +// no fields; a nil error signals the client was revoked. +type RevokeResponse struct{} + // PublishRequest is the input for the Publish RPC. // // Fields: diff --git a/internal/write/hub/hub.go b/internal/write/hub/hub.go index fa5b486fe..136cb8fa2 100644 --- a/internal/write/hub/hub.go +++ b/internal/write/hub/hub.go @@ -63,6 +63,17 @@ func PeerRemoved(cmd *cobra.Command, addr string) { )) } +// Revoked confirms a client token was revoked. +// +// Parameters: +// - cmd: Cobra command for output +// - clientID: ID of the client that was revoked +func Revoked(cmd *cobra.Command, clientID string) { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteHubRevoked), clientID, + )) +} + // SteppedDown confirms leadership transfer. // // Parameters: