Skip to content

Commit 7abbf33

Browse files
bricefclaude
andcommitted
Add API key rotation: PUT /actors/{name}/rotate-key
The service generates a new random key, hashes it with SHA-256, stores the hash, and returns the plaintext key (shown once). The old key is immediately invalidated. Key generation and hashing live in the service layer — the HTTP handler just calls RotateActorKey(ctx, name) and gets the plaintext back. No input required from the caller. CLI: taskflow actor rotate_key <name> MCP: actor_rotate_key tool Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b7be74 commit 7abbf33

11 files changed

Lines changed: 116 additions & 6 deletions

File tree

internal/cli/testdata/commands.golden.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
actor create Create an actor
22
actor get <name> Get an actor by name
33
actor list List all actors
4+
actor rotate_key <name> Rotate an actor's API key
45
actor update <name> Update an actor
56
admin stats System-wide statistics
67
attachment create <slug> <num> Add an attachment

internal/http/handlers.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ func generateAPIKey() (string, error) {
5959
return base64.RawURLEncoding.EncodeToString(b), nil
6060
}
6161

62+
func (s *Server) rotateActorKey(ctx context.Context, r *http.Request) (any, error) {
63+
name := urlParamStr(r, "name")
64+
65+
apiKey, err := s.svc.RotateActorKey(ctx, name)
66+
if err != nil {
67+
return nil, err
68+
}
69+
70+
actor, err := s.svc.GetActor(ctx, name)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
type actorWithKey struct {
76+
model.Actor
77+
APIKey string `json:"api_key"`
78+
}
79+
return actorWithKey{Actor: actor, APIKey: apiKey}, nil
80+
}
81+
6282
// --- Boards ---
6383

6484
func (s *Server) listBoards(ctx context.Context, r *http.Request) (any, error) {

internal/http/routes.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ func (s *Server) resourceHandlers() map[string]handler {
137137
func (s *Server) operationHandlers() map[string]handler {
138138
return map[string]handler{
139139
// Actors
140-
"actor_create": s.createActor,
141-
"actor_update": s.updateActor,
140+
"actor_create": s.createActor,
141+
"actor_rotate_key": s.rotateActorKey,
142+
"actor_update": s.updateActor,
142143

143144
// Boards
144145
"board_create": jsonBody(s.svc.CreateBoard),

internal/http/testdata/openapi.golden.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,52 @@
10411041
]
10421042
}
10431043
},
1044+
"/actors/{name}/rotate-key": {
1045+
"put": {
1046+
"operationId": "actor_rotate_key",
1047+
"parameters": [
1048+
{
1049+
"in": "path",
1050+
"name": "name",
1051+
"required": true,
1052+
"schema": {
1053+
"type": "string"
1054+
}
1055+
}
1056+
],
1057+
"responses": {
1058+
"204": {
1059+
"content": {
1060+
"application/json": {
1061+
"schema": {
1062+
"$ref": "#/components/schemas/Actor"
1063+
}
1064+
}
1065+
},
1066+
"description": "Success"
1067+
},
1068+
"4XX": {
1069+
"content": {
1070+
"application/json": {
1071+
"schema": {
1072+
"$ref": "#/components/schemas/Error"
1073+
}
1074+
}
1075+
},
1076+
"description": "Client error"
1077+
}
1078+
},
1079+
"security": [
1080+
{
1081+
"bearerAuth": []
1082+
}
1083+
],
1084+
"summary": "Rotate an actor's API key",
1085+
"tags": [
1086+
"actors"
1087+
]
1088+
}
1089+
},
10441090
"/admin/stats": {
10451091
"get": {
10461092
"operationId": "admin_stats",

internal/model/operations.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,9 @@ func Operations() []Operation {
236236
// Actors
237237
Create("/actors", "Create an actor").Name("actor_create").Role(RoleAdmin).
238238
Input(CreateActorParams{}).Output(Actor{}).Build(),
239+
CustomAction(ActionSet, "/actors/{name}/rotate-key", "Rotate an actor's API key").Name("actor_rotate_key").Role(RoleAdmin).
240+
Desc("Generate a new API key for the actor. The old key is immediately invalidated. The new key is returned in the response (shown once).").
241+
Output(Actor{}).Build(),
239242
Update("/actors/{name}", "Update an actor").Name("actor_update").Role(RoleAdmin).
240243
Desc("Update actor details. Set --active false to deactivate (revokes API key access, preserves audit history). Set --active true to reactivate.").
241244
Input(UpdateActorParams{}).Output(Actor{}).Build(),

internal/model/operations_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,14 @@ func TestResourcesCount(t *testing.T) {
141141
}
142142

143143
func TestOperationsCount(t *testing.T) {
144-
if got := len(Operations()); got != 23 {
145-
t.Errorf("Operations() returned %d, want 23 — update this test if the change was intentional", got)
144+
if got := len(Operations()); got != 24 {
145+
t.Errorf("Operations() returned %d, want 24 — update this test if the change was intentional", got)
146146
}
147147
}
148148

149149
func TestTotalCount(t *testing.T) {
150150
total := len(Resources()) + len(Operations())
151-
if total != 42 {
152-
t.Errorf("Resources() + Operations() = %d, want 42", total)
151+
if total != 43 {
152+
t.Errorf("Resources() + Operations() = %d, want 43", total)
153153
}
154154
}

internal/model/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ var (
2626
// Named operations — exported for direct use by httpclient consumers.
2727
var (
2828
OpActorCreate = mustOp("actor_create")
29+
OpActorRotateKey = mustOp("actor_rotate_key")
2930
OpActorUpdate = mustOp("actor_update")
3031
OpBoardCreate = mustOp("board_create")
3132
OpBoardUpdate = mustOp("board_update")

internal/repo/repo.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type ActorRepo interface {
2929
ActorGetByAPIKeyHash(ctx context.Context, hash string) (model.Actor, error)
3030
ActorList(ctx context.Context) ([]model.Actor, error)
3131
ActorUpdate(ctx context.Context, tx Tx, params model.UpdateActorParams) (model.Actor, error)
32+
ActorUpdateKeyHash(ctx context.Context, name, newHash string) error
3233
}
3334

3435
type BoardRepo interface {

internal/service/actors.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ package service
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/hex"
9+
"fmt"
510

611
"github.com/bricef/taskflow/internal/model"
712
"github.com/bricef/taskflow/internal/repo"
@@ -35,6 +40,26 @@ func (s *Service) GetActorByAPIKeyHash(ctx context.Context, hash string) (model.
3540
return s.store.ActorGetByAPIKeyHash(ctx, hash)
3641
}
3742

43+
// RotateActorKey generates a new API key for the actor, stores the hash,
44+
// and returns the plaintext key (shown once).
45+
func (s *Service) RotateActorKey(ctx context.Context, name string) (string, error) {
46+
b := make([]byte, 32)
47+
if _, err := rand.Read(b); err != nil {
48+
return "", fmt.Errorf("generating API key: %w", err)
49+
}
50+
apiKey := base64.RawURLEncoding.EncodeToString(b)
51+
hash := sha256Hex(apiKey)
52+
if err := s.store.ActorUpdateKeyHash(ctx, name, hash); err != nil {
53+
return "", err
54+
}
55+
return apiKey, nil
56+
}
57+
58+
func sha256Hex(s string) string {
59+
h := sha256.Sum256([]byte(s))
60+
return hex.EncodeToString(h[:])
61+
}
62+
3863
func (s *Service) ListActors(ctx context.Context) ([]model.Actor, error) {
3964
return s.store.ActorList(ctx)
4065
}

internal/sqlite/actors.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,14 @@ func (s *Store) ActorUpdate(ctx context.Context, tx repo.Tx, params model.Update
103103
}
104104
return s.actorGet(ctx, tx, params.Name)
105105
}
106+
107+
func (s *Store) ActorUpdateKeyHash(ctx context.Context, name, newHash string) error {
108+
result, err := s.db.ExecContext(ctx, "UPDATE actors SET api_key_hash = ? WHERE name = ?", newHash, name)
109+
if err != nil {
110+
return err
111+
}
112+
if n, _ := result.RowsAffected(); n == 0 {
113+
return notFound("actor", name)
114+
}
115+
return nil
116+
}

0 commit comments

Comments
 (0)