ScuttleBot
feat: API key management — per-consumer tokens with scoped permissions (#124) Add APIKeyStore (internal/auth/apikeys.go) with SHA-256 hashed tokens, 8 permission scopes (admin, agents, channels, topology, bots, config, read, chat), per-key expiry, and last-used tracking. Auth middleware now resolves Bearer tokens to API keys, injects into request context, and enforces per-route scope requirements. Every endpoint is scoped: admin for management, agents for registration, channels/chat for messaging, etc. API key CRUD endpoints: POST/GET/DELETE /v1/api-keys (admin scope). Create returns plaintext token once (like GitHub PATs). Login creates 24h session keys instead of returning the shared token. Startup token migrated to api_keys.json as first admin-scope key. MCP server updated to use TokenValidator interface.
485e07b5edede205dc5afcbe044100031cca358fa1c16c0d0ab4bd82b63fe737
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -138,18 +138,31 @@ | ||
| 138 | 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | 139 | log.Error("registry load", "err", err) |
| 140 | 140 | os.Exit(1) |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | - // Shared API token — persisted so the UI token survives restarts. | |
| 144 | - apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) | |
| 143 | + // API key store — per-consumer tokens with scoped permissions. | |
| 144 | + apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json")) | |
| 145 | 145 | if err != nil { |
| 146 | - log.Error("api token", "err", err) | |
| 146 | + log.Error("api key store", "err", err) | |
| 147 | 147 | os.Exit(1) |
| 148 | 148 | } |
| 149 | - log.Info("api token", "token", apiToken) // printed on every startup | |
| 150 | - tokens := []string{apiToken} | |
| 149 | + // Migrate legacy api_token into key store on first run. | |
| 150 | + if apiKeyStore.IsEmpty() { | |
| 151 | + apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) | |
| 152 | + if err != nil { | |
| 153 | + log.Error("api token", "err", err) | |
| 154 | + os.Exit(1) | |
| 155 | + } | |
| 156 | + if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil { | |
| 157 | + log.Error("migrate api token to key store", "err", err) | |
| 158 | + os.Exit(1) | |
| 159 | + } | |
| 160 | + log.Info("migrated api_token to api_keys.json", "token", apiToken) | |
| 161 | + } else { | |
| 162 | + log.Info("api key store loaded", "keys", len(apiKeyStore.List())) | |
| 163 | + } | |
| 151 | 164 | |
| 152 | 165 | // Start bridge bot (powers the web chat UI). |
| 153 | 166 | var bridgeBot *bridge.Bot |
| 154 | 167 | if cfg.Bridge.Enabled { |
| 155 | 168 | if cfg.Bridge.Password == "" { |
| @@ -352,11 +365,11 @@ | ||
| 352 | 365 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | 366 | var topoIface api.TopologyManager |
| 354 | 367 | if topoMgr != nil { |
| 355 | 368 | topoIface = topoMgr |
| 356 | 369 | } |
| 357 | - apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 370 | + apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) | |
| 358 | 371 | handler := apiSrv.Handler() |
| 359 | 372 | |
| 360 | 373 | var httpServer, tlsServer *http.Server |
| 361 | 374 | |
| 362 | 375 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +431,11 @@ | ||
| 418 | 431 | } |
| 419 | 432 | }() |
| 420 | 433 | } |
| 421 | 434 | |
| 422 | 435 | // Start MCP server. |
| 423 | - mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) | |
| 436 | + mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) | |
| 424 | 437 | mcpServer := &http.Server{ |
| 425 | 438 | Addr: cfg.MCPAddr, |
| 426 | 439 | Handler: mcpSrv.Handler(), |
| 427 | 440 | } |
| 428 | 441 | go func() { |
| 429 | 442 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -138,18 +138,31 @@ | |
| 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | log.Error("registry load", "err", err) |
| 140 | os.Exit(1) |
| 141 | } |
| 142 | |
| 143 | // Shared API token — persisted so the UI token survives restarts. |
| 144 | apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) |
| 145 | if err != nil { |
| 146 | log.Error("api token", "err", err) |
| 147 | os.Exit(1) |
| 148 | } |
| 149 | log.Info("api token", "token", apiToken) // printed on every startup |
| 150 | tokens := []string{apiToken} |
| 151 | |
| 152 | // Start bridge bot (powers the web chat UI). |
| 153 | var bridgeBot *bridge.Bot |
| 154 | if cfg.Bridge.Enabled { |
| 155 | if cfg.Bridge.Password == "" { |
| @@ -352,11 +365,11 @@ | |
| 352 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 353 | var topoIface api.TopologyManager |
| 354 | if topoMgr != nil { |
| 355 | topoIface = topoMgr |
| 356 | } |
| 357 | apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 358 | handler := apiSrv.Handler() |
| 359 | |
| 360 | var httpServer, tlsServer *http.Server |
| 361 | |
| 362 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +431,11 @@ | |
| 418 | } |
| 419 | }() |
| 420 | } |
| 421 | |
| 422 | // Start MCP server. |
| 423 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log) |
| 424 | mcpServer := &http.Server{ |
| 425 | Addr: cfg.MCPAddr, |
| 426 | Handler: mcpSrv.Handler(), |
| 427 | } |
| 428 | go func() { |
| 429 |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -138,18 +138,31 @@ | |
| 138 | } else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
| 139 | log.Error("registry load", "err", err) |
| 140 | os.Exit(1) |
| 141 | } |
| 142 | |
| 143 | // API key store — per-consumer tokens with scoped permissions. |
| 144 | apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json")) |
| 145 | if err != nil { |
| 146 | log.Error("api key store", "err", err) |
| 147 | os.Exit(1) |
| 148 | } |
| 149 | // Migrate legacy api_token into key store on first run. |
| 150 | if apiKeyStore.IsEmpty() { |
| 151 | apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) |
| 152 | if err != nil { |
| 153 | log.Error("api token", "err", err) |
| 154 | os.Exit(1) |
| 155 | } |
| 156 | if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil { |
| 157 | log.Error("migrate api token to key store", "err", err) |
| 158 | os.Exit(1) |
| 159 | } |
| 160 | log.Info("migrated api_token to api_keys.json", "token", apiToken) |
| 161 | } else { |
| 162 | log.Info("api key store loaded", "keys", len(apiKeyStore.List())) |
| 163 | } |
| 164 | |
| 165 | // Start bridge bot (powers the web chat UI). |
| 166 | var bridgeBot *bridge.Bot |
| 167 | if cfg.Bridge.Enabled { |
| 168 | if cfg.Bridge.Password == "" { |
| @@ -352,11 +365,11 @@ | |
| 365 | // non-nil (Go nil interface trap) and causes panics in setAgentModes. |
| 366 | var topoIface api.TopologyManager |
| 367 | if topoMgr != nil { |
| 368 | topoIface = topoMgr |
| 369 | } |
| 370 | apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
| 371 | handler := apiSrv.Handler() |
| 372 | |
| 373 | var httpServer, tlsServer *http.Server |
| 374 | |
| 375 | if cfg.TLS.Domain != "" { |
| @@ -418,11 +431,11 @@ | |
| 431 | } |
| 432 | }() |
| 433 | } |
| 434 | |
| 435 | // Start MCP server. |
| 436 | mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) |
| 437 | mcpServer := &http.Server{ |
| 438 | Addr: cfg.MCPAddr, |
| 439 | Handler: mcpSrv.Handler(), |
| 440 | } |
| 441 | go func() { |
| 442 |
| --- internal/api/api_test.go | ||
| +++ internal/api/api_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "net/http/httptest" |
| 9 | 9 | "sync" |
| 10 | 10 | "testing" |
| 11 | 11 | |
| 12 | 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | "log/slog" |
| 15 | 16 | "os" |
| 16 | 17 | ) |
| 17 | 18 | |
| @@ -50,11 +51,11 @@ | ||
| 50 | 51 | const testToken = "test-api-token-abc123" |
| 51 | 52 | |
| 52 | 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | 54 | t.Helper() |
| 54 | 55 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 56 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) | |
| 56 | 57 | return httptest.NewServer(srv.Handler()) |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | func authHeader() http.Header { |
| 60 | 61 | h := http.Header{} |
| 61 | 62 | |
| 62 | 63 | ADDED internal/api/apikeys.go |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | "log/slog" |
| 15 | "os" |
| 16 | ) |
| 17 | |
| @@ -50,11 +51,11 @@ | |
| 50 | const testToken = "test-api-token-abc123" |
| 51 | |
| 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | t.Helper() |
| 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 56 | return httptest.NewServer(srv.Handler()) |
| 57 | } |
| 58 | |
| 59 | func authHeader() http.Header { |
| 60 | h := http.Header{} |
| 61 | |
| 62 | DDED internal/api/apikeys.go |
| --- internal/api/api_test.go | |
| +++ internal/api/api_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | "log/slog" |
| 16 | "os" |
| 17 | ) |
| 18 | |
| @@ -50,11 +51,11 @@ | |
| 51 | const testToken = "test-api-token-abc123" |
| 52 | |
| 53 | func newTestServer(t *testing.T) *httptest.Server { |
| 54 | t.Helper() |
| 55 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 56 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) |
| 57 | return httptest.NewServer(srv.Handler()) |
| 58 | } |
| 59 | |
| 60 | func authHeader() http.Header { |
| 61 | h := http.Header{} |
| 62 | |
| 63 | DDED internal/api/apikeys.go |
| --- a/internal/api/apikeys.go | ||
| +++ b/internal/api/apikeys.go | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type createAPIKeyRequest struct { | |
| 12 | + Name string `json:"name"` | |
| 13 | + Scopes []string `json:"scopes"` | |
| 14 | + ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never | |
| 15 | +} | |
| 16 | + | |
| 17 | +type createAPIKeyResponse struc { | |
| 18 | + ID string `json:"i`json:"nam"` | |
| 19 | + Token string `json:"token"` // plaintext, shown only once | |
| 20 | + Scopes `json:"created_at"` | |
| 21 | + api | |
| 22 | + | |
| 23 | +import ( | |
| 24 | + "encodipackage api | |
| 25 | + | |
| 26 | +import ( | |
| 27 | + "encodion:"expires_at,omitempty"` | |
| 28 | +} | |
| 29 | + { | |
| 30 | + ID string `json:"iruct { | |
| 31 | + Name string `json:"name"` | |
| 32 | + Scopes `json:"created_a`json:"last_used,omitempt`json:"expires_at,omitempt"` | |
| 33 | + Active bool |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,33 @@ | |
| --- a/internal/api/apikeys.go | |
| +++ b/internal/api/apikeys.go | |
| @@ -0,0 +1,33 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 9 | ) |
| 10 | |
| 11 | type createAPIKeyRequest struct { |
| 12 | Name string `json:"name"` |
| 13 | Scopes []string `json:"scopes"` |
| 14 | ExpiresIn string `json:"expires_in,omitempty"` // e.g. "720h" for 30 days, empty = never |
| 15 | } |
| 16 | |
| 17 | type createAPIKeyResponse struc { |
| 18 | ID string `json:"i`json:"nam"` |
| 19 | Token string `json:"token"` // plaintext, shown only once |
| 20 | Scopes `json:"created_at"` |
| 21 | api |
| 22 | |
| 23 | import ( |
| 24 | "encodipackage api |
| 25 | |
| 26 | import ( |
| 27 | "encodion:"expires_at,omitempty"` |
| 28 | } |
| 29 | { |
| 30 | ID string `json:"iruct { |
| 31 | Name string `json:"name"` |
| 32 | Scopes `json:"created_a`json:"last_used,omitempt`json:"expires_at,omitempt"` |
| 33 | Active bool |
| --- internal/api/channels_topology_test.go | ||
| +++ internal/api/channels_topology_test.go | ||
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | 18 | ) |
| 18 | 19 | |
| @@ -74,11 +75,11 @@ | ||
| 74 | 75 | |
| 75 | 76 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | 77 | t.Helper() |
| 77 | 78 | reg := registry.New(nil, []byte("key")) |
| 78 | 79 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 79 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 80 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 80 | 81 | t.Cleanup(srv.Close) |
| 81 | 82 | return srv, "tok" |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +86,11 @@ | ||
| 85 | 86 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | 87 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | 88 | t.Helper() |
| 88 | 89 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | 90 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 90 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 91 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) | |
| 91 | 92 | t.Cleanup(srv.Close) |
| 92 | 93 | return srv, "tok" |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 | 97 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 17 | ) |
| 18 | |
| @@ -74,11 +75,11 @@ | |
| 74 | |
| 75 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 76 | t.Helper() |
| 77 | reg := registry.New(nil, []byte("key")) |
| 78 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 79 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 80 | t.Cleanup(srv.Close) |
| 81 | return srv, "tok" |
| 82 | } |
| 83 | |
| 84 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +86,11 @@ | |
| 85 | // real registry backed by stubProvisioner, so agent registration works. |
| 86 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 87 | t.Helper() |
| 88 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 89 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 90 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 91 | t.Cleanup(srv.Close) |
| 92 | return srv, "tok" |
| 93 | } |
| 94 | |
| 95 | func TestHandleProvisionChannel(t *testing.T) { |
| 96 |
| --- internal/api/channels_topology_test.go | |
| +++ internal/api/channels_topology_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 17 | "github.com/conflicthq/scuttlebot/internal/topology" |
| 18 | ) |
| 19 | |
| @@ -74,11 +75,11 @@ | |
| 75 | |
| 76 | func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 77 | t.Helper() |
| 78 | reg := registry.New(nil, []byte("key")) |
| 79 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 80 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 81 | t.Cleanup(srv.Close) |
| 82 | return srv, "tok" |
| 83 | } |
| 84 | |
| 85 | // newTopoTestServerWithRegistry creates a test server with both topology and a |
| @@ -85,11 +86,11 @@ | |
| 86 | // real registry backed by stubProvisioner, so agent registration works. |
| 87 | func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) { |
| 88 | t.Helper() |
| 89 | reg := registry.New(newStubProvisioner(), []byte("key")) |
| 90 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 91 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, topo, nil, "", log).Handler()) |
| 92 | t.Cleanup(srv.Close) |
| 93 | return srv, "tok" |
| 94 | } |
| 95 | |
| 96 | func TestHandleProvisionChannel(t *testing.T) { |
| 97 |
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "net/http" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 10 | 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | 15 | type chatBridge interface { |
| @@ -118,11 +119,12 @@ | ||
| 118 | 119 | |
| 119 | 120 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | 121 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | 122 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | 123 | token := r.URL.Query().Get("token") |
| 123 | - if _, ok := s.tokens[token]; !ok { | |
| 124 | + key := s.apiKeys.Lookup(token) | |
| 125 | + if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) { | |
| 124 | 126 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | 127 | return |
| 126 | 128 | } |
| 127 | 129 | |
| 128 | 130 | channel := "#" + r.PathValue("channel") |
| 129 | 131 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -5,10 +5,11 @@ | |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 11 | ) |
| 12 | |
| 13 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 14 | type chatBridge interface { |
| @@ -118,11 +119,12 @@ | |
| 118 | |
| 119 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 120 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 121 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 122 | token := r.URL.Query().Get("token") |
| 123 | if _, ok := s.tokens[token]; !ok { |
| 124 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 125 | return |
| 126 | } |
| 127 | |
| 128 | channel := "#" + r.PathValue("channel") |
| 129 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -5,10 +5,11 @@ | |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 11 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 12 | ) |
| 13 | |
| 14 | // chatBridge is the interface the API layer requires from the bridge bot. |
| 15 | type chatBridge interface { |
| @@ -118,11 +119,12 @@ | |
| 119 | |
| 120 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 121 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 122 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 123 | token := r.URL.Query().Get("token") |
| 124 | key := s.apiKeys.Lookup(token) |
| 125 | if key == nil || (!key.HasScope(auth.ScopeChannels) && !key.HasScope(auth.ScopeChat)) { |
| 126 | writeError(w, http.StatusUnauthorized, "invalid or missing token") |
| 127 | return |
| 128 | } |
| 129 | |
| 130 | channel := "#" + r.PathValue("channel") |
| 131 |
| --- internal/api/chat_test.go | ||
| +++ internal/api/chat_test.go | ||
| @@ -8,10 +8,11 @@ | ||
| 8 | 8 | "log/slog" |
| 9 | 9 | "net/http" |
| 10 | 10 | "net/http/httptest" |
| 11 | 11 | "testing" |
| 12 | 12 | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 18 | type stubChatBridge struct { |
| @@ -42,11 +43,11 @@ | ||
| 42 | 43 | t.Helper() |
| 43 | 44 | |
| 44 | 45 | bridgeStub := &stubChatBridge{} |
| 45 | 46 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | 47 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 47 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 48 | + srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 48 | 49 | defer srv.Close() |
| 49 | 50 | |
| 50 | 51 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | 52 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | 53 | if err != nil { |
| @@ -75,11 +76,11 @@ | ||
| 75 | 76 | t.Helper() |
| 76 | 77 | |
| 77 | 78 | bridgeStub := &stubChatBridge{} |
| 78 | 79 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | 80 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 80 | - srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 81 | + srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) | |
| 81 | 82 | defer srv.Close() |
| 82 | 83 | |
| 83 | 84 | body, _ := json.Marshal(map[string]string{}) |
| 84 | 85 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | 86 | if err != nil { |
| 86 | 87 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | type stubChatBridge struct { |
| @@ -42,11 +43,11 @@ | |
| 42 | t.Helper() |
| 43 | |
| 44 | bridgeStub := &stubChatBridge{} |
| 45 | reg := registry.New(nil, []byte("test-signing-key")) |
| 46 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 47 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 48 | defer srv.Close() |
| 49 | |
| 50 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 51 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 52 | if err != nil { |
| @@ -75,11 +76,11 @@ | |
| 75 | t.Helper() |
| 76 | |
| 77 | bridgeStub := &stubChatBridge{} |
| 78 | reg := registry.New(nil, []byte("test-signing-key")) |
| 79 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 80 | srv := httptest.NewServer(New(reg, []string{"token"}, bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 81 | defer srv.Close() |
| 82 | |
| 83 | body, _ := json.Marshal(map[string]string{}) |
| 84 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 85 | if err != nil { |
| 86 |
| --- internal/api/chat_test.go | |
| +++ internal/api/chat_test.go | |
| @@ -8,10 +8,11 @@ | |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 14 | "github.com/conflicthq/scuttlebot/internal/bots/bridge" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | type stubChatBridge struct { |
| @@ -42,11 +43,11 @@ | |
| 43 | t.Helper() |
| 44 | |
| 45 | bridgeStub := &stubChatBridge{} |
| 46 | reg := registry.New(nil, []byte("test-signing-key")) |
| 47 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 48 | srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 49 | defer srv.Close() |
| 50 | |
| 51 | body, _ := json.Marshal(map[string]string{"nick": "codex-test"}) |
| 52 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 53 | if err != nil { |
| @@ -75,11 +76,11 @@ | |
| 76 | t.Helper() |
| 77 | |
| 78 | bridgeStub := &stubChatBridge{} |
| 79 | reg := registry.New(nil, []byte("test-signing-key")) |
| 80 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 81 | srv := httptest.NewServer(New(reg, auth.TestStore("token"), bridgeStub, nil, nil, nil, nil, nil, "", logger).Handler()) |
| 82 | defer srv.Close() |
| 83 | |
| 84 | body, _ := json.Marshal(map[string]string{}) |
| 85 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/v1/channels/general/presence", bytes.NewReader(body)) |
| 86 | if err != nil { |
| 87 |
| --- internal/api/config_handlers_test.go | ||
| +++ internal/api/config_handlers_test.go | ||
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | "net/http/httptest" |
| 10 | 10 | "path/filepath" |
| 11 | 11 | "testing" |
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 14 | 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | 17 | ) |
| 17 | 18 | |
| 18 | 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | ||
| 25 | 26 | cfg.Ergo.DataDir = dir |
| 26 | 27 | |
| 27 | 28 | store := NewConfigStore(path, cfg) |
| 28 | 29 | reg := registry.New(nil, []byte("key")) |
| 29 | 30 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 30 | - srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler()) | |
| 31 | + srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler()) | |
| 31 | 32 | t.Cleanup(srv.Close) |
| 32 | 33 | return srv, store |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | func TestHandleGetConfig(t *testing.T) { |
| 36 | 37 |
| --- internal/api/config_handlers_test.go | |
| +++ internal/api/config_handlers_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/config" |
| 15 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | |
| 25 | cfg.Ergo.DataDir = dir |
| 26 | |
| 27 | store := NewConfigStore(path, cfg) |
| 28 | reg := registry.New(nil, []byte("key")) |
| 29 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 30 | srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, nil, store, "", log).Handler()) |
| 31 | t.Cleanup(srv.Close) |
| 32 | return srv, store |
| 33 | } |
| 34 | |
| 35 | func TestHandleGetConfig(t *testing.T) { |
| 36 |
| --- internal/api/config_handlers_test.go | |
| +++ internal/api/config_handlers_test.go | |
| @@ -9,10 +9,11 @@ | |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 15 | "github.com/conflicthq/scuttlebot/internal/config" |
| 16 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 17 | ) |
| 18 | |
| 19 | func newCfgTestServer(t *testing.T) (*httptest.Server, *ConfigStore) { |
| @@ -25,11 +26,11 @@ | |
| 26 | cfg.Ergo.DataDir = dir |
| 27 | |
| 28 | store := NewConfigStore(path, cfg) |
| 29 | reg := registry.New(nil, []byte("key")) |
| 30 | log := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 31 | srv := httptest.NewServer(New(reg, auth.TestStore("tok"), nil, nil, nil, nil, nil, store, "", log).Handler()) |
| 32 | t.Cleanup(srv.Close) |
| 33 | return srv, store |
| 34 | } |
| 35 | |
| 36 | func TestHandleGetConfig(t *testing.T) { |
| 37 |
| --- internal/api/login.go | ||
| +++ internal/api/login.go | ||
| @@ -93,15 +93,17 @@ | ||
| 93 | 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | 95 | return |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | - // Return the first API token — the shared server token. | |
| 99 | - var token string | |
| 100 | - for t := range s.tokens { | |
| 101 | - token = t | |
| 102 | - break | |
| 98 | + // Create a session API key for this admin login. | |
| 99 | + sessionName := "session:" + req.Username | |
| 100 | + token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour)) | |
| 101 | + if err != nil { | |
| 102 | + s.log.Error("login: create session key", "err", err) | |
| 103 | + writeError(w, http.StatusInternalServerError, "failed to create session") | |
| 104 | + return | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | 108 | "token": token, |
| 107 | 109 | "username": req.Username, |
| 108 | 110 |
| --- internal/api/login.go | |
| +++ internal/api/login.go | |
| @@ -93,15 +93,17 @@ | |
| 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | return |
| 96 | } |
| 97 | |
| 98 | // Return the first API token — the shared server token. |
| 99 | var token string |
| 100 | for t := range s.tokens { |
| 101 | token = t |
| 102 | break |
| 103 | } |
| 104 | |
| 105 | writeJSON(w, http.StatusOK, map[string]string{ |
| 106 | "token": token, |
| 107 | "username": req.Username, |
| 108 |
| --- internal/api/login.go | |
| +++ internal/api/login.go | |
| @@ -93,15 +93,17 @@ | |
| 93 | if !s.admins.Authenticate(req.Username, req.Password) { |
| 94 | writeError(w, http.StatusUnauthorized, "invalid credentials") |
| 95 | return |
| 96 | } |
| 97 | |
| 98 | // Create a session API key for this admin login. |
| 99 | sessionName := "session:" + req.Username |
| 100 | token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour)) |
| 101 | if err != nil { |
| 102 | s.log.Error("login: create session key", "err", err) |
| 103 | writeError(w, http.StatusInternalServerError, "failed to create session") |
| 104 | return |
| 105 | } |
| 106 | |
| 107 | writeJSON(w, http.StatusOK, map[string]string{ |
| 108 | "token": token, |
| 109 | "username": req.Username, |
| 110 |
| --- internal/api/login_test.go | ||
| +++ internal/api/login_test.go | ||
| @@ -28,18 +28,18 @@ | ||
| 28 | 28 | admins := newAdminStore(t) |
| 29 | 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | 30 | t.Fatalf("Add admin: %v", err) |
| 31 | 31 | } |
| 32 | 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | - srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) | |
| 33 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog) | |
| 34 | 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | 38 | // When admins is nil, login returns 404. |
| 39 | 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | - srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) | |
| 40 | + srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) | |
| 41 | 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | 42 | defer ts.Close() |
| 43 | 43 | |
| 44 | 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | 45 | defer resp.Body.Close() |
| 46 | 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, []string{testToken}, nil, nil, admins, nil, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
| --- internal/api/login_test.go | |
| +++ internal/api/login_test.go | |
| @@ -28,18 +28,18 @@ | |
| 28 | admins := newAdminStore(t) |
| 29 | if err := admins.Add("admin", "hunter2"); err != nil { |
| 30 | t.Fatalf("Add admin: %v", err) |
| 31 | } |
| 32 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 33 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, admins, nil, nil, nil, "", testLog) |
| 34 | return httptest.NewServer(srv.Handler()), admins |
| 35 | } |
| 36 | |
| 37 | func TestLoginNoAdmins(t *testing.T) { |
| 38 | // When admins is nil, login returns 404. |
| 39 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 40 | srv := api.New(reg, auth.TestStore(testToken), nil, nil, nil, nil, nil, nil, "", testLog) |
| 41 | ts := httptest.NewServer(srv.Handler()) |
| 42 | defer ts.Close() |
| 43 | |
| 44 | resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil) |
| 45 | defer resp.Body.Close() |
| 46 |
| --- internal/api/middleware.go | ||
| +++ internal/api/middleware.go | ||
| @@ -1,26 +1,62 @@ | ||
| 1 | 1 | package api |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "strings" |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 6 | 9 | ) |
| 7 | 10 | |
| 11 | +type ctxKey string | |
| 12 | + | |
| 13 | +const ctxAPIKey ctxKey = "apikey" | |
| 14 | + | |
| 15 | +// apiKeyFromContext returns the authenticated APIKey from the request context, | |
| 16 | +// or nil if not authenticated. | |
| 17 | +func apiKeyFromContext(ctx context.Context) *auth.APIKey { | |
| 18 | + k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey) | |
| 19 | + return k | |
| 20 | +} | |
| 21 | + | |
| 22 | +// authMiddleware validates the Bearer token and injects the APIKey into context. | |
| 8 | 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | 25 | token := bearerToken(r) |
| 11 | 26 | if token == "" { |
| 12 | 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | 28 | return |
| 14 | 29 | } |
| 15 | - if _, ok := s.tokens[token]; !ok { | |
| 30 | + key := s.apiKeys.Lookup(token) | |
| 31 | + if key == nil { | |
| 16 | 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | 33 | return |
| 18 | 34 | } |
| 19 | - next.ServeHTTP(w, r) | |
| 35 | + // Update last-used timestamp in the background. | |
| 36 | + go s.apiKeys.TouchLastUsed(key.ID) | |
| 37 | + | |
| 38 | + ctx := context.WithValue(r.Context(), ctxAPIKey, key) | |
| 39 | + next.ServeHTTP(w, r.WithContext(ctx)) | |
| 20 | 40 | }) |
| 21 | 41 | } |
| 42 | + | |
| 43 | +// requireScope returns middleware that rejects requests without the given scope. | |
| 44 | +func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc { | |
| 45 | + return func(w http.ResponseWriter, r *http.Request) { | |
| 46 | + key := apiKeyFromContext(r.Context()) | |
| 47 | + if key == nil { | |
| 48 | + writeError(w, http.StatusUnauthorized, "missing authentication") | |
| 49 | + return | |
| 50 | + } | |
| 51 | + if !key.HasScope(scope) { | |
| 52 | + writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope)) | |
| 53 | + return | |
| 54 | + } | |
| 55 | + next(w, r) | |
| 56 | + } | |
| 57 | +} | |
| 22 | 58 | |
| 23 | 59 | func bearerToken(r *http.Request) string { |
| 24 | 60 | auth := r.Header.Get("Authorization") |
| 25 | 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | 62 | if !found { |
| 27 | 63 |
| --- internal/api/middleware.go | |
| +++ internal/api/middleware.go | |
| @@ -1,26 +1,62 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | token := bearerToken(r) |
| 11 | if token == "" { |
| 12 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | return |
| 14 | } |
| 15 | if _, ok := s.tokens[token]; !ok { |
| 16 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 17 | return |
| 18 | } |
| 19 | next.ServeHTTP(w, r) |
| 20 | }) |
| 21 | } |
| 22 | |
| 23 | func bearerToken(r *http.Request) string { |
| 24 | auth := r.Header.Get("Authorization") |
| 25 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 26 | if !found { |
| 27 |
| --- internal/api/middleware.go | |
| +++ internal/api/middleware.go | |
| @@ -1,26 +1,62 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 9 | ) |
| 10 | |
| 11 | type ctxKey string |
| 12 | |
| 13 | const ctxAPIKey ctxKey = "apikey" |
| 14 | |
| 15 | // apiKeyFromContext returns the authenticated APIKey from the request context, |
| 16 | // or nil if not authenticated. |
| 17 | func apiKeyFromContext(ctx context.Context) *auth.APIKey { |
| 18 | k, _ := ctx.Value(ctxAPIKey).(*auth.APIKey) |
| 19 | return k |
| 20 | } |
| 21 | |
| 22 | // authMiddleware validates the Bearer token and injects the APIKey into context. |
| 23 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | token := bearerToken(r) |
| 26 | if token == "" { |
| 27 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 28 | return |
| 29 | } |
| 30 | key := s.apiKeys.Lookup(token) |
| 31 | if key == nil { |
| 32 | writeError(w, http.StatusUnauthorized, "invalid token") |
| 33 | return |
| 34 | } |
| 35 | // Update last-used timestamp in the background. |
| 36 | go s.apiKeys.TouchLastUsed(key.ID) |
| 37 | |
| 38 | ctx := context.WithValue(r.Context(), ctxAPIKey, key) |
| 39 | next.ServeHTTP(w, r.WithContext(ctx)) |
| 40 | }) |
| 41 | } |
| 42 | |
| 43 | // requireScope returns middleware that rejects requests without the given scope. |
| 44 | func (s *Server) requireScope(scope auth.Scope, next http.HandlerFunc) http.HandlerFunc { |
| 45 | return func(w http.ResponseWriter, r *http.Request) { |
| 46 | key := apiKeyFromContext(r.Context()) |
| 47 | if key == nil { |
| 48 | writeError(w, http.StatusUnauthorized, "missing authentication") |
| 49 | return |
| 50 | } |
| 51 | if !key.HasScope(scope) { |
| 52 | writeError(w, http.StatusForbidden, "insufficient scope: requires "+string(scope)) |
| 53 | return |
| 54 | } |
| 55 | next(w, r) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func bearerToken(r *http.Request) string { |
| 60 | auth := r.Header.Get("Authorization") |
| 61 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 62 | if !found { |
| 63 |
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -7,18 +7,19 @@ | ||
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | 9 | "log/slog" |
| 10 | 10 | "net/http" |
| 11 | 11 | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/auth" | |
| 12 | 13 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | 15 | ) |
| 15 | 16 | |
| 16 | 17 | // Server is the scuttlebot HTTP API server. |
| 17 | 18 | type Server struct { |
| 18 | 19 | registry *registry.Registry |
| 19 | - tokens map[string]struct{} | |
| 20 | + apiKeys *auth.APIKeyStore | |
| 20 | 21 | log *slog.Logger |
| 21 | 22 | bridge chatBridge // nil if bridge is disabled |
| 22 | 23 | policies *PolicyStore // nil if not configured |
| 23 | 24 | admins adminStore // nil if not configured |
| 24 | 25 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | ||
| 31 | 32 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | 33 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | 34 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | 35 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | 36 | // Pass nil for cfgStore to disable config read/write endpoints. |
| 36 | -func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { | |
| 37 | - tokenSet := make(map[string]struct{}, len(tokens)) | |
| 38 | - for _, t := range tokens { | |
| 39 | - tokenSet[t] = struct{}{} | |
| 40 | - } | |
| 37 | +func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { | |
| 41 | 38 | return &Server{ |
| 42 | 39 | registry: reg, |
| 43 | - tokens: tokenSet, | |
| 40 | + apiKeys: apiKeys, | |
| 44 | 41 | log: log, |
| 45 | 42 | bridge: b, |
| 46 | 43 | policies: ps, |
| 47 | 44 | admins: admins, |
| 48 | 45 | llmCfg: llmCfg, |
| @@ -53,65 +50,84 @@ | ||
| 53 | 50 | } |
| 54 | 51 | } |
| 55 | 52 | |
| 56 | 53 | // Handler returns the HTTP handler with all routes registered. |
| 57 | 54 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 55 | +// Scoped routes additionally check the API key's scopes. | |
| 58 | 56 | func (s *Server) Handler() http.Handler { |
| 59 | 57 | apiMux := http.NewServeMux() |
| 60 | - apiMux.HandleFunc("GET /v1/status", s.handleStatus) | |
| 61 | - apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics) | |
| 62 | - if s.policies != nil { | |
| 63 | - apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings) | |
| 64 | - apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) | |
| 65 | - apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) | |
| 66 | - apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies) | |
| 67 | - } | |
| 68 | - apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) | |
| 69 | - apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) | |
| 70 | - apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent) | |
| 71 | - apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) | |
| 72 | - apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) | |
| 73 | - apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) | |
| 74 | - apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) | |
| 75 | - apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) | |
| 76 | - if s.bridge != nil { | |
| 77 | - apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) | |
| 78 | - apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) | |
| 79 | - apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel) | |
| 80 | - apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) | |
| 81 | - apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) | |
| 82 | - apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence) | |
| 83 | - apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers) | |
| 84 | - } | |
| 85 | - if s.topoMgr != nil { | |
| 86 | - apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) | |
| 87 | - apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) | |
| 88 | - apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) | |
| 89 | - } | |
| 90 | - if s.cfgStore != nil { | |
| 91 | - apiMux.HandleFunc("GET /v1/config", s.handleGetConfig) | |
| 92 | - apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig) | |
| 93 | - apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory) | |
| 94 | - apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry) | |
| 95 | - } | |
| 96 | - | |
| 97 | - if s.admins != nil { | |
| 98 | - apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) | |
| 99 | - apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) | |
| 100 | - apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) | |
| 101 | - apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword) | |
| 102 | - } | |
| 103 | - | |
| 104 | - // LLM / AI gateway endpoints. | |
| 105 | - apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends) | |
| 106 | - apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate) | |
| 107 | - apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate) | |
| 108 | - apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete) | |
| 109 | - apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels) | |
| 110 | - apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover) | |
| 111 | - apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown) | |
| 112 | - apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete) | |
| 58 | + | |
| 59 | + // Read-scope: status, metrics (also accessible with any scope via admin). | |
| 60 | + apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus)) | |
| 61 | + apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics)) | |
| 62 | + | |
| 63 | + // Policies — admin scope. | |
| 64 | + if s.policies != nil { | |
| 65 | + apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings)) | |
| 66 | + apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies)) | |
| 67 | + apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies)) | |
| 68 | + apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies)) | |
| 69 | + } | |
| 70 | + | |
| 71 | + // Agents — agents scope. | |
| 72 | + apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents)) | |
| 73 | + apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent)) | |
| 74 | + apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent)) | |
| 75 | + apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) | |
| 76 | + apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) | |
| 77 | + apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) | |
| 78 | + apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) | |
| 79 | + apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) | |
| 80 | + | |
| 81 | + // Channels — channels scope (read), chat scope (send). | |
| 82 | + if s.bridge != nil { | |
| 83 | + apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) | |
| 84 | + apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) | |
| 85 | + apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) | |
| 86 | + apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) | |
| 87 | + apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) | |
| 88 | + apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) | |
| 89 | + apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) | |
| 90 | + } | |
| 91 | + | |
| 92 | + // Topology — topology scope. | |
| 93 | + if s.topoMgr != nil { | |
| 94 | + apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) | |
| 95 | + apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel)) | |
| 96 | + apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology)) | |
| 97 | + } | |
| 98 | + | |
| 99 | + // Config — config scope. | |
| 100 | + if s.cfgStore != nil { | |
| 101 | + apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig)) | |
| 102 | + apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig)) | |
| 103 | + apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory)) | |
| 104 | + apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry)) | |
| 105 | + } | |
| 106 | + | |
| 107 | + // Admin — admin scope. | |
| 108 | + if s.admins != nil { | |
| 109 | + apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList)) | |
| 110 | + apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd)) | |
| 111 | + apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove)) | |
| 112 | + apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword)) | |
| 113 | + } | |
| 114 | + | |
| 115 | + // API key management — admin scope. | |
| 116 | + apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys)) | |
| 117 | + apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey)) | |
| 118 | + apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey)) | |
| 119 | + | |
| 120 | + // LLM / AI gateway — bots scope. | |
| 121 | + apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends)) | |
| 122 | + apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate)) | |
| 123 | + apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate)) | |
| 124 | + apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete)) | |
| 125 | + apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels)) | |
| 126 | + apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover)) | |
| 127 | + apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown)) | |
| 128 | + apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete)) | |
| 113 | 129 | |
| 114 | 130 | outer := http.NewServeMux() |
| 115 | 131 | outer.HandleFunc("POST /login", s.handleLogin) |
| 116 | 132 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 117 | 133 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 118 | 134 | |
| 119 | 135 | ADDED internal/auth/apikeys.go |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -7,18 +7,19 @@ | |
| 7 | |
| 8 | import ( |
| 9 | "log/slog" |
| 10 | "net/http" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/config" |
| 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | ) |
| 15 | |
| 16 | // Server is the scuttlebot HTTP API server. |
| 17 | type Server struct { |
| 18 | registry *registry.Registry |
| 19 | tokens map[string]struct{} |
| 20 | log *slog.Logger |
| 21 | bridge chatBridge // nil if bridge is disabled |
| 22 | policies *PolicyStore // nil if not configured |
| 23 | admins adminStore // nil if not configured |
| 24 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | |
| 31 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 32 | // Pass nil for admins to disable admin authentication endpoints. |
| 33 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 34 | // Pass nil for topo to disable topology provisioning endpoints. |
| 35 | // Pass nil for cfgStore to disable config read/write endpoints. |
| 36 | func New(reg *registry.Registry, tokens []string, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { |
| 37 | tokenSet := make(map[string]struct{}, len(tokens)) |
| 38 | for _, t := range tokens { |
| 39 | tokenSet[t] = struct{}{} |
| 40 | } |
| 41 | return &Server{ |
| 42 | registry: reg, |
| 43 | tokens: tokenSet, |
| 44 | log: log, |
| 45 | bridge: b, |
| 46 | policies: ps, |
| 47 | admins: admins, |
| 48 | llmCfg: llmCfg, |
| @@ -53,65 +50,84 @@ | |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // Handler returns the HTTP handler with all routes registered. |
| 57 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 58 | func (s *Server) Handler() http.Handler { |
| 59 | apiMux := http.NewServeMux() |
| 60 | apiMux.HandleFunc("GET /v1/status", s.handleStatus) |
| 61 | apiMux.HandleFunc("GET /v1/metrics", s.handleMetrics) |
| 62 | if s.policies != nil { |
| 63 | apiMux.HandleFunc("GET /v1/settings", s.handleGetSettings) |
| 64 | apiMux.HandleFunc("GET /v1/settings/policies", s.handleGetPolicies) |
| 65 | apiMux.HandleFunc("PUT /v1/settings/policies", s.handlePutPolicies) |
| 66 | apiMux.HandleFunc("PATCH /v1/settings/policies", s.handlePatchPolicies) |
| 67 | } |
| 68 | apiMux.HandleFunc("GET /v1/agents", s.handleListAgents) |
| 69 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.handleGetAgent) |
| 70 | apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.handleUpdateAgent) |
| 71 | apiMux.HandleFunc("POST /v1/agents/register", s.handleRegister) |
| 72 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.handleRotate) |
| 73 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.handleAdopt) |
| 74 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke) |
| 75 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete) |
| 76 | if s.bridge != nil { |
| 77 | apiMux.HandleFunc("GET /v1/channels", s.handleListChannels) |
| 78 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel) |
| 79 | apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel) |
| 80 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages) |
| 81 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage) |
| 82 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence) |
| 83 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers) |
| 84 | } |
| 85 | if s.topoMgr != nil { |
| 86 | apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel) |
| 87 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel) |
| 88 | apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology) |
| 89 | } |
| 90 | if s.cfgStore != nil { |
| 91 | apiMux.HandleFunc("GET /v1/config", s.handleGetConfig) |
| 92 | apiMux.HandleFunc("PUT /v1/config", s.handlePutConfig) |
| 93 | apiMux.HandleFunc("GET /v1/config/history", s.handleGetConfigHistory) |
| 94 | apiMux.HandleFunc("GET /v1/config/history/{filename}", s.handleGetConfigHistoryEntry) |
| 95 | } |
| 96 | |
| 97 | if s.admins != nil { |
| 98 | apiMux.HandleFunc("GET /v1/admins", s.handleAdminList) |
| 99 | apiMux.HandleFunc("POST /v1/admins", s.handleAdminAdd) |
| 100 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.handleAdminRemove) |
| 101 | apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.handleAdminSetPassword) |
| 102 | } |
| 103 | |
| 104 | // LLM / AI gateway endpoints. |
| 105 | apiMux.HandleFunc("GET /v1/llm/backends", s.handleLLMBackends) |
| 106 | apiMux.HandleFunc("POST /v1/llm/backends", s.handleLLMBackendCreate) |
| 107 | apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.handleLLMBackendUpdate) |
| 108 | apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.handleLLMBackendDelete) |
| 109 | apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.handleLLMModels) |
| 110 | apiMux.HandleFunc("POST /v1/llm/discover", s.handleLLMDiscover) |
| 111 | apiMux.HandleFunc("GET /v1/llm/known", s.handleLLMKnown) |
| 112 | apiMux.HandleFunc("POST /v1/llm/complete", s.handleLLMComplete) |
| 113 | |
| 114 | outer := http.NewServeMux() |
| 115 | outer.HandleFunc("POST /login", s.handleLogin) |
| 116 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 117 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 118 | |
| 119 | DDED internal/auth/apikeys.go |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -7,18 +7,19 @@ | |
| 7 | |
| 8 | import ( |
| 9 | "log/slog" |
| 10 | "net/http" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/auth" |
| 13 | "github.com/conflicthq/scuttlebot/internal/config" |
| 14 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | // Server is the scuttlebot HTTP API server. |
| 18 | type Server struct { |
| 19 | registry *registry.Registry |
| 20 | apiKeys *auth.APIKeyStore |
| 21 | log *slog.Logger |
| 22 | bridge chatBridge // nil if bridge is disabled |
| 23 | policies *PolicyStore // nil if not configured |
| 24 | admins adminStore // nil if not configured |
| 25 | llmCfg *config.LLMConfig // nil if no LLM backends configured |
| @@ -31,18 +32,14 @@ | |
| 32 | // New creates a new API Server. Pass nil for b to disable the chat bridge. |
| 33 | // Pass nil for admins to disable admin authentication endpoints. |
| 34 | // Pass nil for llmCfg to disable AI/LLM management endpoints. |
| 35 | // Pass nil for topo to disable topology provisioning endpoints. |
| 36 | // Pass nil for cfgStore to disable config read/write endpoints. |
| 37 | func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server { |
| 38 | return &Server{ |
| 39 | registry: reg, |
| 40 | apiKeys: apiKeys, |
| 41 | log: log, |
| 42 | bridge: b, |
| 43 | policies: ps, |
| 44 | admins: admins, |
| 45 | llmCfg: llmCfg, |
| @@ -53,65 +50,84 @@ | |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // Handler returns the HTTP handler with all routes registered. |
| 54 | // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated. |
| 55 | // Scoped routes additionally check the API key's scopes. |
| 56 | func (s *Server) Handler() http.Handler { |
| 57 | apiMux := http.NewServeMux() |
| 58 | |
| 59 | // Read-scope: status, metrics (also accessible with any scope via admin). |
| 60 | apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus)) |
| 61 | apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics)) |
| 62 | |
| 63 | // Policies — admin scope. |
| 64 | if s.policies != nil { |
| 65 | apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings)) |
| 66 | apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies)) |
| 67 | apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies)) |
| 68 | apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies)) |
| 69 | } |
| 70 | |
| 71 | // Agents — agents scope. |
| 72 | apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents)) |
| 73 | apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent)) |
| 74 | apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent)) |
| 75 | apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister)) |
| 76 | apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate)) |
| 77 | apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt)) |
| 78 | apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke)) |
| 79 | apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete)) |
| 80 | |
| 81 | // Channels — channels scope (read), chat scope (send). |
| 82 | if s.bridge != nil { |
| 83 | apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels)) |
| 84 | apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel)) |
| 85 | apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) |
| 86 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) |
| 87 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) |
| 88 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) |
| 89 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) |
| 90 | } |
| 91 | |
| 92 | // Topology — topology scope. |
| 93 | if s.topoMgr != nil { |
| 94 | apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
| 95 | apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel)) |
| 96 | apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology)) |
| 97 | } |
| 98 | |
| 99 | // Config — config scope. |
| 100 | if s.cfgStore != nil { |
| 101 | apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig)) |
| 102 | apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig)) |
| 103 | apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory)) |
| 104 | apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry)) |
| 105 | } |
| 106 | |
| 107 | // Admin — admin scope. |
| 108 | if s.admins != nil { |
| 109 | apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList)) |
| 110 | apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd)) |
| 111 | apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove)) |
| 112 | apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword)) |
| 113 | } |
| 114 | |
| 115 | // API key management — admin scope. |
| 116 | apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys)) |
| 117 | apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey)) |
| 118 | apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey)) |
| 119 | |
| 120 | // LLM / AI gateway — bots scope. |
| 121 | apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends)) |
| 122 | apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate)) |
| 123 | apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate)) |
| 124 | apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete)) |
| 125 | apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels)) |
| 126 | apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover)) |
| 127 | apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown)) |
| 128 | apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete)) |
| 129 | |
| 130 | outer := http.NewServeMux() |
| 131 | outer.HandleFunc("POST /login", s.handleLogin) |
| 132 | outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 133 | http.Redirect(w, r, "/ui/", http.StatusFound) |
| 134 | |
| 135 | DDED internal/auth/apikeys.go |
| --- a/internal/auth/apikeys.go | ||
| +++ b/internal/auth/apikeys.go | ||
| @@ -0,0 +1,211 @@ | ||
| 1 | +package auth | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/rand" | |
| 5 | + "crypto/sha256" | |
| 6 | + "encoding/hex" | |
| 7 | + "encoding/json" | |
| 8 | + "fmt" | |
| 9 | + "os" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "github.com/oklog/ulid/v2" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// Scope represents a permission scope for an API key. | |
| 18 | +type Scope string | |
| 19 | + | |
| 20 | +const ( | |
| 21 | + ScopeAdmin Scope = "admin" // full access | |
| 22 | + ScopeAgents Scope = "agents" // agent registration, rotation, revocation | |
| 23 | + ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence | |
| 24 | + ScopeTopology Scope = "topology" // channel provisioning, topology management | |
| 25 | + ScopeBots Scope = "bots" // bot configuration, start/stop | |
| 26 | + ScopeConfig Scope = "config" // server config read/write | |
| 27 | + ScopeRead Scope = "read" // read-only access to all GET endpoints | |
| 28 | + ScopeChat Scope = "chat" // send/receive messages only | |
| 29 | +) | |
| 30 | + | |
| 31 | +// ValidScopes is the set of all recognised scopes. | |
| 32 | +var ValidScopes = map[Scope]bool{ | |
| 33 | + ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true, | |
| 34 | + ScopeTopology: true, ScopeBots: true, ScopeConfig: true, | |
| 35 | + ScopeRead: true, ScopeChat: true, | |
| 36 | +} | |
| 37 | + | |
| 38 | +// APIKey is a single API key record. | |
| 39 | +type APIKey struct { | |
| 40 | + ID string `json:"id"` | |
| 41 | + Name string `json:"name"` | |
| 42 | + Hash string `json:"hash"` // SHA-256 of the plaintext token | |
| 43 | + Scopes []Scope `json:"scopes"` | |
| 44 | + CreatedAt time.Time `json:"created_at"` | |
| 45 | + LastUsed time.Time `json:"last_used,omitempty"` | |
| 46 | + ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never | |
| 47 | + Active bool `json:"active"` | |
| 48 | +} | |
| 49 | + | |
| 50 | +// HasScope reports whether the key has the given scope (or admin, which implies all). | |
| 51 | +func (k *APIKey) HasScope(s Scope) bool { | |
| 52 | + for _, scope := range k.Scopes { | |
| 53 | + if scope == ScopeAdmin || scope == s { | |
| 54 | + return true | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return false | |
| 58 | +} | |
| 59 | + | |
| 60 | +// IsExpired reports whether the key has passed its expiry time. | |
| 61 | +func (k *APIKey) IsExpired() bool { | |
| 62 | + return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt) | |
| 63 | +} | |
| 64 | + | |
| 65 | +// APIKeyStore persists API keys to a JSON file. | |
| 66 | +type APIKeyStore struct { | |
| 67 | + mu sync.RWMutex | |
| 68 | + path string | |
| 69 | + data []APIKey | |
| 70 | +} | |
| 71 | + | |
| 72 | +// NewAPIKeyStore loads (or creates) the API key store at the given path. | |
| 73 | +func NewAPIKeyStore(path string) (*APIKeyStore, error) { | |
| 74 | + s := &APIKeyStore{path: path} | |
| 75 | + if err := s.load(); err != nil { | |
| 76 | + return nil, err | |
| 77 | + } | |
| 78 | + return s, nil | |
| 79 | +} | |
| 80 | + | |
| 81 | +// Create generates a new API key with the given name and scopes. | |
| 82 | +// Returns the plaintext token (shown only once) and the stored key record. | |
| 83 | +func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) { | |
| 84 | + s.mu.Lock() | |
| 85 | + defer s.mu.Unlock() | |
| 86 | + | |
| 87 | + token, err := genToken() | |
| 88 | + if err != nil { | |
| 89 | + return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err) | |
| 90 | + } | |
| 91 | + | |
| 92 | + key = APIKey{ | |
| 93 | + ID: newULID(), | |
| 94 | + Name: name, | |
| 95 | + Hash: hashToken(token), | |
| 96 | + Scopes: scopes, | |
| 97 | + CreatedAt: time.Now().UTC(), | |
| 98 | + ExpiresAt: expiresAt, | |
| 99 | + Active: true, | |
| 100 | + } | |
| 101 | + s.data = append(s.data, key) | |
| 102 | + if err := s.save(); err != nil { | |
| 103 | + // Roll back. | |
| 104 | + s.data = s.data[:len(s.data)-1] | |
| 105 | + return "", APIKey{}, err | |
| 106 | + } | |
| 107 | + return token, key, nil | |
| 108 | +} | |
| 109 | + | |
| 110 | +// Insert adds a pre-built API key with a known plaintext token. | |
| 111 | +// Used for migrating the startup token into the store. | |
| 112 | +func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) { | |
| 113 | + s.mu.Lock() | |
| 114 | + defer s.mu.Unlock() | |
| 115 | + | |
| 116 | + key := APIKey{ | |
| 117 | + ID: newULID(), | |
| 118 | + Name: name, | |
| 119 | + Hash: hashToken(plaintext), | |
| 120 | + Scopes: scopes, | |
| 121 | + CreatedAt: time.Now().UTC(), | |
| 122 | + Active: true, | |
| 123 | + } | |
| 124 | + s.data = append(s.data, key) | |
| 125 | + if err := s.save(); err != nil { | |
| 126 | + s.data = s.data[:len(s.data)-1] | |
| 127 | + return APIKey{}, err | |
| 128 | + } | |
| 129 | + return key, nil | |
| 130 | +} | |
| 131 | + | |
| 132 | +// Lookup finds an active, non-expired key by plaintext token. | |
| 133 | +// Returns nil if no match. | |
| 134 | +func (s *APIKeyStore) Lookup(token string) *APIKey { | |
| 135 | + hash := hashToken(token) | |
| 136 | + s.mu.RLock() | |
| 137 | + defer s.mu.RUnlock() | |
| 138 | + for i := range s.data { | |
| 139 | + if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() { | |
| 140 | + k := s.data[i] | |
| 141 | + return &k | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return nil | |
| 145 | +} | |
| 146 | + | |
| 147 | +// TouchLastUsed updates the last-used timestamp for a key by ID. | |
| 148 | +func (s *APIKeyStore) TouchLastUsed(id string) { | |
| 149 | + s.mu.Lock() | |
| 150 | + defer s.mu.Unlock() | |
| 151 | + for i := range s.data { | |
| 152 | + if s.data[i].ID == id { | |
| 153 | + s.data[i].LastUsed = time.Now().UTC() | |
| 154 | + _ = s.save() // best-effort persistence | |
| 155 | + return | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +// Get returns a key by ID, or nil if not found. | |
| 161 | +func (s *APIKeyStore) Get(id string) *APIKey { | |
| 162 | + s.mu.RLock() | |
| 163 | + defer s.mu.RUnlock() | |
| 164 | + for i := range s.data { | |
| 165 | + if s.data[i].ID == id { | |
| 166 | + k := s.data[i] | |
| 167 | + return &k | |
| 168 | + } | |
| 169 | + } | |
| 170 | + return nil | |
| 171 | +} | |
| 172 | + | |
| 173 | +// List returns all keys (active and revoked). | |
| 174 | +func (s *APIKeyStore) List() []APIKey { | |
| 175 | + s.mu.RLock() | |
| 176 | + defer s.mu.RUnlock() | |
| 177 | + out := make([]APIKey, len(s.data)) | |
| 178 | + copy(out, s.data) | |
| 179 | + return out | |
| 180 | +} | |
| 181 | + | |
| 182 | +// Revoke deactivates a key by ID. | |
| 183 | +func (s *APIKeyStore) Revoke(id string) error { | |
| 184 | + s.mu.Lock() | |
| 185 | + defer s.mu.Unlock() | |
| 186 | + for i := range s.data { | |
| 187 | + if s.data[i].ID == id { | |
| 188 | + if !s.data[i].Active { | |
| 189 | + return fmt.Errorf("apikeys: key %q already revoked", id) | |
| 190 | + } | |
| 191 | + s.data[i].Active = false | |
| 192 | + return s.save() | |
| 193 | + } | |
| 194 | + } | |
| 195 | + return fmt.Errorf("apikeys: key %q not found", id) | |
| 196 | +} | |
| 197 | + | |
| 198 | +// Lookup (TokenValidator interface) reports whether the token is valid. | |
| 199 | +// Satisfies the mcp.TokenValidator interface. | |
| 200 | +func (s *APIKeyStore) ValidToken(token string) bool { | |
| 201 | + return s.Lookup(token) != nil | |
| 202 | +} | |
| 203 | + | |
| 204 | +// TestStore creates an in-memory APIKeyStore with a single admin-scope key | |
| 205 | +// for the given token. Intended for tests only — does not persist to disk. | |
| 206 | +func TestStore(token string) *APIKeyStore { | |
| 207 | + s := &APIKeyStore{path: "", data: []APIKey{{ | |
| 208 | + ID: "test-key", | |
| 209 | + Name: "test", | |
| 210 | + Hash: hashToken(token), | |
| 211 | + Scopes: []Scop |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,211 @@ | |
| --- a/internal/auth/apikeys.go | |
| +++ b/internal/auth/apikeys.go | |
| @@ -0,0 +1,211 @@ | |
| 1 | package auth |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/oklog/ulid/v2" |
| 15 | ) |
| 16 | |
| 17 | // Scope represents a permission scope for an API key. |
| 18 | type Scope string |
| 19 | |
| 20 | const ( |
| 21 | ScopeAdmin Scope = "admin" // full access |
| 22 | ScopeAgents Scope = "agents" // agent registration, rotation, revocation |
| 23 | ScopeChannels Scope = "channels" // channel CRUD, join, messages, presence |
| 24 | ScopeTopology Scope = "topology" // channel provisioning, topology management |
| 25 | ScopeBots Scope = "bots" // bot configuration, start/stop |
| 26 | ScopeConfig Scope = "config" // server config read/write |
| 27 | ScopeRead Scope = "read" // read-only access to all GET endpoints |
| 28 | ScopeChat Scope = "chat" // send/receive messages only |
| 29 | ) |
| 30 | |
| 31 | // ValidScopes is the set of all recognised scopes. |
| 32 | var ValidScopes = map[Scope]bool{ |
| 33 | ScopeAdmin: true, ScopeAgents: true, ScopeChannels: true, |
| 34 | ScopeTopology: true, ScopeBots: true, ScopeConfig: true, |
| 35 | ScopeRead: true, ScopeChat: true, |
| 36 | } |
| 37 | |
| 38 | // APIKey is a single API key record. |
| 39 | type APIKey struct { |
| 40 | ID string `json:"id"` |
| 41 | Name string `json:"name"` |
| 42 | Hash string `json:"hash"` // SHA-256 of the plaintext token |
| 43 | Scopes []Scope `json:"scopes"` |
| 44 | CreatedAt time.Time `json:"created_at"` |
| 45 | LastUsed time.Time `json:"last_used,omitempty"` |
| 46 | ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never |
| 47 | Active bool `json:"active"` |
| 48 | } |
| 49 | |
| 50 | // HasScope reports whether the key has the given scope (or admin, which implies all). |
| 51 | func (k *APIKey) HasScope(s Scope) bool { |
| 52 | for _, scope := range k.Scopes { |
| 53 | if scope == ScopeAdmin || scope == s { |
| 54 | return true |
| 55 | } |
| 56 | } |
| 57 | return false |
| 58 | } |
| 59 | |
| 60 | // IsExpired reports whether the key has passed its expiry time. |
| 61 | func (k *APIKey) IsExpired() bool { |
| 62 | return !k.ExpiresAt.IsZero() && time.Now().After(k.ExpiresAt) |
| 63 | } |
| 64 | |
| 65 | // APIKeyStore persists API keys to a JSON file. |
| 66 | type APIKeyStore struct { |
| 67 | mu sync.RWMutex |
| 68 | path string |
| 69 | data []APIKey |
| 70 | } |
| 71 | |
| 72 | // NewAPIKeyStore loads (or creates) the API key store at the given path. |
| 73 | func NewAPIKeyStore(path string) (*APIKeyStore, error) { |
| 74 | s := &APIKeyStore{path: path} |
| 75 | if err := s.load(); err != nil { |
| 76 | return nil, err |
| 77 | } |
| 78 | return s, nil |
| 79 | } |
| 80 | |
| 81 | // Create generates a new API key with the given name and scopes. |
| 82 | // Returns the plaintext token (shown only once) and the stored key record. |
| 83 | func (s *APIKeyStore) Create(name string, scopes []Scope, expiresAt time.Time) (plaintext string, key APIKey, err error) { |
| 84 | s.mu.Lock() |
| 85 | defer s.mu.Unlock() |
| 86 | |
| 87 | token, err := genToken() |
| 88 | if err != nil { |
| 89 | return "", APIKey{}, fmt.Errorf("apikeys: generate token: %w", err) |
| 90 | } |
| 91 | |
| 92 | key = APIKey{ |
| 93 | ID: newULID(), |
| 94 | Name: name, |
| 95 | Hash: hashToken(token), |
| 96 | Scopes: scopes, |
| 97 | CreatedAt: time.Now().UTC(), |
| 98 | ExpiresAt: expiresAt, |
| 99 | Active: true, |
| 100 | } |
| 101 | s.data = append(s.data, key) |
| 102 | if err := s.save(); err != nil { |
| 103 | // Roll back. |
| 104 | s.data = s.data[:len(s.data)-1] |
| 105 | return "", APIKey{}, err |
| 106 | } |
| 107 | return token, key, nil |
| 108 | } |
| 109 | |
| 110 | // Insert adds a pre-built API key with a known plaintext token. |
| 111 | // Used for migrating the startup token into the store. |
| 112 | func (s *APIKeyStore) Insert(name, plaintext string, scopes []Scope) (APIKey, error) { |
| 113 | s.mu.Lock() |
| 114 | defer s.mu.Unlock() |
| 115 | |
| 116 | key := APIKey{ |
| 117 | ID: newULID(), |
| 118 | Name: name, |
| 119 | Hash: hashToken(plaintext), |
| 120 | Scopes: scopes, |
| 121 | CreatedAt: time.Now().UTC(), |
| 122 | Active: true, |
| 123 | } |
| 124 | s.data = append(s.data, key) |
| 125 | if err := s.save(); err != nil { |
| 126 | s.data = s.data[:len(s.data)-1] |
| 127 | return APIKey{}, err |
| 128 | } |
| 129 | return key, nil |
| 130 | } |
| 131 | |
| 132 | // Lookup finds an active, non-expired key by plaintext token. |
| 133 | // Returns nil if no match. |
| 134 | func (s *APIKeyStore) Lookup(token string) *APIKey { |
| 135 | hash := hashToken(token) |
| 136 | s.mu.RLock() |
| 137 | defer s.mu.RUnlock() |
| 138 | for i := range s.data { |
| 139 | if s.data[i].Hash == hash && s.data[i].Active && !s.data[i].IsExpired() { |
| 140 | k := s.data[i] |
| 141 | return &k |
| 142 | } |
| 143 | } |
| 144 | return nil |
| 145 | } |
| 146 | |
| 147 | // TouchLastUsed updates the last-used timestamp for a key by ID. |
| 148 | func (s *APIKeyStore) TouchLastUsed(id string) { |
| 149 | s.mu.Lock() |
| 150 | defer s.mu.Unlock() |
| 151 | for i := range s.data { |
| 152 | if s.data[i].ID == id { |
| 153 | s.data[i].LastUsed = time.Now().UTC() |
| 154 | _ = s.save() // best-effort persistence |
| 155 | return |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Get returns a key by ID, or nil if not found. |
| 161 | func (s *APIKeyStore) Get(id string) *APIKey { |
| 162 | s.mu.RLock() |
| 163 | defer s.mu.RUnlock() |
| 164 | for i := range s.data { |
| 165 | if s.data[i].ID == id { |
| 166 | k := s.data[i] |
| 167 | return &k |
| 168 | } |
| 169 | } |
| 170 | return nil |
| 171 | } |
| 172 | |
| 173 | // List returns all keys (active and revoked). |
| 174 | func (s *APIKeyStore) List() []APIKey { |
| 175 | s.mu.RLock() |
| 176 | defer s.mu.RUnlock() |
| 177 | out := make([]APIKey, len(s.data)) |
| 178 | copy(out, s.data) |
| 179 | return out |
| 180 | } |
| 181 | |
| 182 | // Revoke deactivates a key by ID. |
| 183 | func (s *APIKeyStore) Revoke(id string) error { |
| 184 | s.mu.Lock() |
| 185 | defer s.mu.Unlock() |
| 186 | for i := range s.data { |
| 187 | if s.data[i].ID == id { |
| 188 | if !s.data[i].Active { |
| 189 | return fmt.Errorf("apikeys: key %q already revoked", id) |
| 190 | } |
| 191 | s.data[i].Active = false |
| 192 | return s.save() |
| 193 | } |
| 194 | } |
| 195 | return fmt.Errorf("apikeys: key %q not found", id) |
| 196 | } |
| 197 | |
| 198 | // Lookup (TokenValidator interface) reports whether the token is valid. |
| 199 | // Satisfies the mcp.TokenValidator interface. |
| 200 | func (s *APIKeyStore) ValidToken(token string) bool { |
| 201 | return s.Lookup(token) != nil |
| 202 | } |
| 203 | |
| 204 | // TestStore creates an in-memory APIKeyStore with a single admin-scope key |
| 205 | // for the given token. Intended for tests only — does not persist to disk. |
| 206 | func TestStore(token string) *APIKeyStore { |
| 207 | s := &APIKeyStore{path: "", data: []APIKey{{ |
| 208 | ID: "test-key", |
| 209 | Name: "test", |
| 210 | Hash: hashToken(token), |
| 211 | Scopes: []Scop |
| --- internal/mcp/mcp.go | ||
| +++ internal/mcp/mcp.go | ||
| @@ -52,31 +52,32 @@ | ||
| 52 | 52 | type ChannelInfo struct { |
| 53 | 53 | Name string `json:"name"` |
| 54 | 54 | Topic string `json:"topic,omitempty"` |
| 55 | 55 | Count int `json:"count"` |
| 56 | 56 | } |
| 57 | + | |
| 58 | +// TokenValidator validates API tokens. | |
| 59 | +type TokenValidator interface { | |
| 60 | + ValidToken(token string) bool | |
| 61 | +} | |
| 57 | 62 | |
| 58 | 63 | // Server is the MCP server. |
| 59 | 64 | type Server struct { |
| 60 | 65 | registry *registry.Registry |
| 61 | 66 | channels ChannelLister |
| 62 | 67 | sender Sender // optional — send_message returns error if nil |
| 63 | 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | - tokens map[string]struct{} | |
| 69 | + tokens TokenValidator | |
| 65 | 70 | log *slog.Logger |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | 73 | // New creates an MCP Server. |
| 69 | -func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server { | |
| 70 | - t := make(map[string]struct{}, len(tokens)) | |
| 71 | - for _, tok := range tokens { | |
| 72 | - t[tok] = struct{}{} | |
| 73 | - } | |
| 74 | +func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server { | |
| 74 | 75 | return &Server{ |
| 75 | 76 | registry: reg, |
| 76 | 77 | channels: channels, |
| 77 | - tokens: t, | |
| 78 | + tokens: tokens, | |
| 78 | 79 | log: log, |
| 79 | 80 | } |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | ||
| 101 | 102 | // --- Auth --- |
| 102 | 103 | |
| 103 | 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | 106 | token := bearerToken(r) |
| 106 | - if _, ok := s.tokens[token]; !ok { | |
| 107 | + if !s.tokens.ValidToken(token) { | |
| 107 | 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | 109 | return |
| 109 | 110 | } |
| 110 | 111 | next.ServeHTTP(w, r) |
| 111 | 112 | }) |
| 112 | 113 |
| --- internal/mcp/mcp.go | |
| +++ internal/mcp/mcp.go | |
| @@ -52,31 +52,32 @@ | |
| 52 | type ChannelInfo struct { |
| 53 | Name string `json:"name"` |
| 54 | Topic string `json:"topic,omitempty"` |
| 55 | Count int `json:"count"` |
| 56 | } |
| 57 | |
| 58 | // Server is the MCP server. |
| 59 | type Server struct { |
| 60 | registry *registry.Registry |
| 61 | channels ChannelLister |
| 62 | sender Sender // optional — send_message returns error if nil |
| 63 | history HistoryQuerier // optional — get_history returns error if nil |
| 64 | tokens map[string]struct{} |
| 65 | log *slog.Logger |
| 66 | } |
| 67 | |
| 68 | // New creates an MCP Server. |
| 69 | func New(reg *registry.Registry, channels ChannelLister, tokens []string, log *slog.Logger) *Server { |
| 70 | t := make(map[string]struct{}, len(tokens)) |
| 71 | for _, tok := range tokens { |
| 72 | t[tok] = struct{}{} |
| 73 | } |
| 74 | return &Server{ |
| 75 | registry: reg, |
| 76 | channels: channels, |
| 77 | tokens: t, |
| 78 | log: log, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | |
| 101 | // --- Auth --- |
| 102 | |
| 103 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 104 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | token := bearerToken(r) |
| 106 | if _, ok := s.tokens[token]; !ok { |
| 107 | writeRPCError(w, nil, -32001, "unauthorized") |
| 108 | return |
| 109 | } |
| 110 | next.ServeHTTP(w, r) |
| 111 | }) |
| 112 |
| --- internal/mcp/mcp.go | |
| +++ internal/mcp/mcp.go | |
| @@ -52,31 +52,32 @@ | |
| 52 | type ChannelInfo struct { |
| 53 | Name string `json:"name"` |
| 54 | Topic string `json:"topic,omitempty"` |
| 55 | Count int `json:"count"` |
| 56 | } |
| 57 | |
| 58 | // TokenValidator validates API tokens. |
| 59 | type TokenValidator interface { |
| 60 | ValidToken(token string) bool |
| 61 | } |
| 62 | |
| 63 | // Server is the MCP server. |
| 64 | type Server struct { |
| 65 | registry *registry.Registry |
| 66 | channels ChannelLister |
| 67 | sender Sender // optional — send_message returns error if nil |
| 68 | history HistoryQuerier // optional — get_history returns error if nil |
| 69 | tokens TokenValidator |
| 70 | log *slog.Logger |
| 71 | } |
| 72 | |
| 73 | // New creates an MCP Server. |
| 74 | func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server { |
| 75 | return &Server{ |
| 76 | registry: reg, |
| 77 | channels: channels, |
| 78 | tokens: tokens, |
| 79 | log: log, |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // WithSender attaches an IRC relay client for send_message. |
| @@ -101,11 +102,11 @@ | |
| 102 | // --- Auth --- |
| 103 | |
| 104 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 105 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 106 | token := bearerToken(r) |
| 107 | if !s.tokens.ValidToken(token) { |
| 108 | writeRPCError(w, nil, -32001, "unauthorized") |
| 109 | return |
| 110 | } |
| 111 | next.ServeHTTP(w, r) |
| 112 | }) |
| 113 |
| --- internal/mcp/mcp_test.go | ||
| +++ internal/mcp/mcp_test.go | ||
| @@ -19,10 +19,17 @@ | ||
| 19 | 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | 20 | |
| 21 | 21 | const testToken = "test-mcp-token" |
| 22 | 22 | |
| 23 | 23 | // --- mocks --- |
| 24 | + | |
| 25 | +type tokenSet map[string]struct{} | |
| 26 | + | |
| 27 | +func (t tokenSet) ValidToken(tok string) bool { | |
| 28 | + _, ok := t[tok] | |
| 29 | + return ok | |
| 30 | +} | |
| 24 | 31 | |
| 25 | 32 | type mockProvisioner struct { |
| 26 | 33 | mu sync.Mutex |
| 27 | 34 | accounts map[string]string |
| 28 | 35 | } |
| @@ -93,11 +100,11 @@ | ||
| 93 | 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | 101 | "#fleet": { |
| 95 | 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | 103 | }, |
| 97 | 104 | }} |
| 98 | - srv := mcp.New(reg, channels, []string{testToken}, testLog). | |
| 105 | + srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). | |
| 99 | 106 | WithSender(sender). |
| 100 | 107 | WithHistory(hist) |
| 101 | 108 | return httptest.NewServer(srv.Handler()) |
| 102 | 109 | } |
| 103 | 110 | |
| 104 | 111 |
| --- internal/mcp/mcp_test.go | |
| +++ internal/mcp/mcp_test.go | |
| @@ -19,10 +19,17 @@ | |
| 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | |
| 21 | const testToken = "test-mcp-token" |
| 22 | |
| 23 | // --- mocks --- |
| 24 | |
| 25 | type mockProvisioner struct { |
| 26 | mu sync.Mutex |
| 27 | accounts map[string]string |
| 28 | } |
| @@ -93,11 +100,11 @@ | |
| 93 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 94 | "#fleet": { |
| 95 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 96 | }, |
| 97 | }} |
| 98 | srv := mcp.New(reg, channels, []string{testToken}, testLog). |
| 99 | WithSender(sender). |
| 100 | WithHistory(hist) |
| 101 | return httptest.NewServer(srv.Handler()) |
| 102 | } |
| 103 | |
| 104 |
| --- internal/mcp/mcp_test.go | |
| +++ internal/mcp/mcp_test.go | |
| @@ -19,10 +19,17 @@ | |
| 19 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 20 | |
| 21 | const testToken = "test-mcp-token" |
| 22 | |
| 23 | // --- mocks --- |
| 24 | |
| 25 | type tokenSet map[string]struct{} |
| 26 | |
| 27 | func (t tokenSet) ValidToken(tok string) bool { |
| 28 | _, ok := t[tok] |
| 29 | return ok |
| 30 | } |
| 31 | |
| 32 | type mockProvisioner struct { |
| 33 | mu sync.Mutex |
| 34 | accounts map[string]string |
| 35 | } |
| @@ -93,11 +100,11 @@ | |
| 100 | hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{ |
| 101 | "#fleet": { |
| 102 | {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`}, |
| 103 | }, |
| 104 | }} |
| 105 | srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog). |
| 106 | WithSender(sender). |
| 107 | WithHistory(hist) |
| 108 | return httptest.NewServer(srv.Handler()) |
| 109 | } |
| 110 | |
| 111 |