diff --git a/docs/thv-registry-api/docs.go b/docs/thv-registry-api/docs.go index df346daf..15512d45 100644 --- a/docs/thv-registry-api/docs.go +++ b/docs/thv-registry-api/docs.go @@ -431,6 +431,15 @@ const docTemplate = `{ }, "type": "object" }, + "internal_api_v1.entryClaimsResponse": { + "properties": { + "claims": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, "internal_api_v1.meResponse": { "properties": { "roles": { @@ -1991,6 +2000,110 @@ const docTemplate = `{ } }, "/v1/entries/{type}/{name}/claims": { + "get": { + "description": "Get the claims for an API-published entry name within the managed source.\nClaims are stored at the entry-name level and are shared by every version of that name.\nSynced-source entries (git/api/file/kubernetes) are out of scope: their claims come from\nupstream (the source manifest or the ` + "`" + `toolhive.stacklok.dev/authz-claims` + "`" + ` annotation) and\nare surfaced through the ` + "`" + `/v1/sources/{name}/entries` + "`" + ` and ` + "`" + `/v1/registries/{name}/entries` + "`" + ` lists.", + "parameters": [ + { + "description": "Entry Type (server or skill)", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Entry Name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_api_v1.entryClaimsResponse" + } + } + }, + "description": "Entry claims" + }, + "400": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Bad request" + }, + "403": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "No managed source available" + } + }, + "summary": "Get entry claims", + "tags": [ + "v1" + ] + }, "put": { "description": "Update claims for a published entry name", "parameters": [ diff --git a/docs/thv-registry-api/swagger.json b/docs/thv-registry-api/swagger.json index 30501608..3e10176b 100644 --- a/docs/thv-registry-api/swagger.json +++ b/docs/thv-registry-api/swagger.json @@ -424,6 +424,15 @@ }, "type": "object" }, + "internal_api_v1.entryClaimsResponse": { + "properties": { + "claims": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, "internal_api_v1.meResponse": { "properties": { "roles": { @@ -1984,6 +1993,110 @@ } }, "/v1/entries/{type}/{name}/claims": { + "get": { + "description": "Get the claims for an API-published entry name within the managed source.\nClaims are stored at the entry-name level and are shared by every version of that name.\nSynced-source entries (git/api/file/kubernetes) are out of scope: their claims come from\nupstream (the source manifest or the `toolhive.stacklok.dev/authz-claims` annotation) and\nare surfaced through the `/v1/sources/{name}/entries` and `/v1/registries/{name}/entries` lists.", + "parameters": [ + { + "description": "Entry Type (server or skill)", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Entry Name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_api_v1.entryClaimsResponse" + } + } + }, + "description": "Entry claims" + }, + "400": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Bad request" + }, + "403": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "No managed source available" + } + }, + "summary": "Get entry claims", + "tags": [ + "v1" + ] + }, "put": { "description": "Update claims for a published entry name", "parameters": [ diff --git a/docs/thv-registry-api/swagger.yaml b/docs/thv-registry-api/swagger.yaml index d8687a11..9b4b790c 100644 --- a/docs/thv-registry-api/swagger.yaml +++ b/docs/thv-registry-api/swagger.yaml @@ -290,6 +290,12 @@ components: description: Number of skills in registry type: integer type: object + internal_api_v1.entryClaimsResponse: + properties: + claims: + additionalProperties: {} + type: object + type: object internal_api_v1.meResponse: properties: roles: @@ -1356,6 +1362,76 @@ paths: tags: - v1 /v1/entries/{type}/{name}/claims: + get: + description: |- + Get the claims for an API-published entry name within the managed source. + Claims are stored at the entry-name level and are shared by every version of that name. + Synced-source entries (git/api/file/kubernetes) are out of scope: their claims come from + upstream (the source manifest or the `toolhive.stacklok.dev/authz-claims` annotation) and + are surfaced through the `/v1/sources/{name}/entries` and `/v1/registries/{name}/entries` lists. + parameters: + - description: Entry Type (server or skill) + in: path + name: type + required: true + schema: + type: string + - description: Entry Name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/internal_api_v1.entryClaimsResponse' + description: Entry claims + "400": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Bad request + "403": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Forbidden + "404": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Not found + "500": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: Internal server error + "503": + content: + application/json: + schema: + additionalProperties: + type: string + type: object + description: No managed source available + summary: Get entry claims + tags: + - v1 put: description: Update claims for a published entry name parameters: diff --git a/internal/api/v1/entries.go b/internal/api/v1/entries.go index 42559351..47c51d80 100644 --- a/internal/api/v1/entries.go +++ b/internal/api/v1/entries.go @@ -185,6 +185,92 @@ func (routes *Routes) deletePublishedEntry(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusNoContent) } +// entryClaimsResponse is the response body for fetching or returning entry claims. +type entryClaimsResponse struct { + Claims map[string]any `json:"claims"` +} + +// getEntryClaims handles GET /v1/entries/{type}/{name}/claims. +// +// Authorization model: +// - manageEntries role gate runs in middleware (see routes.go). +// - Service-layer JWT subset check denies cross-team reads (403) when authz +// is enabled, mirroring the matching PUT. +// - Anonymous mode (no JWT in context): the handler skips WithJWTClaims, the +// gate short-circuits, and any caller reads any entry's claims. Intended, +// but worth knowing if you ever run partial-anonymous deployments. +// +// The response envelope `{"claims": {...}}` is always a non-nil JSON object — +// the impl normalises a missing/nil claims blob to `map[string]any{}`. Under +// authz, that branch is reachable only by super-admin (the publish path +// forbids empty claims per auth.md §6, and the gate denies empty-claim rows +// for everyone else per §4) or by callers in `skipAuthz=true` deployments. +// Either way the response shape stays stable. +// +// @Summary Get entry claims +// @Description Get the claims for an API-published entry name within the managed source. +// @Description Claims are stored at the entry-name level and are shared by every version of that name. +// @Description Synced-source entries (git/api/file/kubernetes) are out of scope: their claims come from +// @Description upstream (the source manifest or the `toolhive.stacklok.dev/authz-claims` annotation) and +// @Description are surfaced through the `/v1/sources/{name}/entries` and `/v1/registries/{name}/entries` lists. +// @Tags v1 +// @Produce json +// @Param type path string true "Entry Type (server or skill)" +// @Param name path string true "Entry Name" +// @Success 200 {object} entryClaimsResponse "Entry claims" +// @Failure 400 {object} map[string]string "Bad request" +// @Failure 403 {object} map[string]string "Forbidden" +// @Failure 404 {object} map[string]string "Not found" +// @Failure 500 {object} map[string]string "Internal server error" +// @Failure 503 {object} map[string]string "No managed source available" +// @Router /v1/entries/{type}/{name}/claims [get] +func (routes *Routes) getEntryClaims(w http.ResponseWriter, r *http.Request) { + entryType, err := common.GetAndValidateURLParam(r, "type") + if err != nil { + common.WriteErrorResponse(w, err.Error(), http.StatusBadRequest) + return + } + + name, err := common.GetAndValidateURLParam(r, "name") + if err != nil { + common.WriteErrorResponse(w, err.Error(), http.StatusBadRequest) + return + } + + opts := []service.Option{ + service.WithEntryType(entryType), + service.WithName(name), + } + if jwtClaims := auth.ClaimsFromContext(r.Context()); jwtClaims != nil { + opts = append(opts, service.WithJWTClaims(map[string]any(jwtClaims))) + } + + claims, err := routes.service.GetEntryClaims(r.Context(), opts...) + if err != nil { + if errors.Is(err, service.ErrInvalidEntryType) { + common.WriteErrorResponse(w, err.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, service.ErrClaimsInsufficient) { + common.WriteErrorResponse(w, err.Error(), http.StatusForbidden) + return + } + if errors.Is(err, service.ErrNotFound) { + common.WriteErrorResponse(w, err.Error(), http.StatusNotFound) + return + } + if errors.Is(err, service.ErrNoManagedSource) { + common.WriteErrorResponse(w, "no managed source available", http.StatusServiceUnavailable) + return + } + slog.ErrorContext(r.Context(), "failed to get entry claims", "error", err, "type", entryType) + common.WriteErrorResponse(w, "failed to get entry claims", http.StatusInternalServerError) + return + } + + common.WriteJSONResponse(w, entryClaimsResponse{Claims: claims}, http.StatusOK) +} + // updateEntryClaimsRequest is the request body for updating entry claims. type updateEntryClaimsRequest struct { Claims map[string]any `json:"claims"` diff --git a/internal/api/v1/entries_test.go b/internal/api/v1/entries_test.go index e1d1c4c3..d0efb012 100644 --- a/internal/api/v1/entries_test.go +++ b/internal/api/v1/entries_test.go @@ -501,6 +501,167 @@ func TestUpdateEntryClaims(t *testing.T) { } } +func TestGetEntryClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + setupMock func(*mocks.MockRegistryService) + wantStatus int + wantClaims map[string]any + wantError string + }{ + { + name: "success - server type", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]any{"org": "acme", "team": "platform"}, nil) + }, + wantStatus: http.StatusOK, + wantClaims: map[string]any{"org": "acme", "team": "platform"}, + }, + { + name: "success - skill type", + path: "/entries/skill/test%2Fskill/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]any{"org": "acme"}, nil) + }, + wantStatus: http.StatusOK, + wantClaims: map[string]any{"org": "acme"}, + }, + { + name: "success - empty claims returns empty object", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]any{}, nil) + }, + wantStatus: http.StatusOK, + wantClaims: map[string]any{}, + }, + { + name: "unsupported entry type from service", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, fmt.Errorf("invalid option: %w", service.ErrInvalidEntryType)) + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid entry type", + }, + { + name: "entry not found", + path: "/entries/server/test%2Fmissing/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, service.ErrNotFound) + }, + wantStatus: http.StatusNotFound, + }, + { + name: "no managed source", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, service.ErrNoManagedSource) + }, + wantStatus: http.StatusServiceUnavailable, + wantError: "no managed source available", + }, + { + name: "claims insufficient", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, service.ErrClaimsInsufficient) + }, + wantStatus: http.StatusForbidden, + wantError: "insufficient claims", + }, + { + name: "generic service error", + path: "/entries/server/test%2Fserver/claims", + setupMock: func(m *mocks.MockRegistryService) { + m.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, fmt.Errorf("unexpected error")) + }, + wantStatus: http.StatusInternalServerError, + wantError: "failed to get entry claims", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockSvc := mocks.NewMockRegistryService(ctrl) + tt.setupMock(mockSvc) + + router := Router(mockSvc, nil) + req, err := http.NewRequest(http.MethodGet, tt.path, nil) + require.NoError(t, err) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatus, rr.Code) + + if tt.wantError != "" { + var response map[string]string + err = json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response["error"], tt.wantError) + } + + if tt.wantStatus == http.StatusOK { + var resp entryClaimsResponse + err = json.Unmarshal(rr.Body.Bytes(), &resp) + require.NoError(t, err) + assert.NotNil(t, resp.Claims, "claims must be a non-nil JSON object") + assert.Equal(t, tt.wantClaims, resp.Claims) + } + }) + } +} + +// TestGetEntryClaims_PassesJWTClaimsToService verifies the handler plumbs JWT +// claims through to the service when they're present in the request context. +// Without this case, the table test above would let a regression slip — its +// mock expectations use exactly three matchers (ctx + 2 options) and would +// fail at runtime if the handler started passing a third option silently. +func TestGetEntryClaims_PassesJWTClaimsToService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockSvc := mocks.NewMockRegistryService(ctrl) + // Four matchers: ctx + WithEntryType + WithName + WithJWTClaims. + mockSvc.EXPECT().GetEntryClaims(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]any{"org": "acme", "team": "platform"}, nil) + + router := Router(mockSvc, nil) + req, err := http.NewRequest(http.MethodGet, "/entries/server/test%2Fserver/claims", nil) + require.NoError(t, err) + req = req.WithContext(auth.ContextWithClaims( + req.Context(), jwt.MapClaims{"org": "acme", "team": "platform"}, + )) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp entryClaimsResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, map[string]any{"org": "acme", "team": "platform"}, resp.Claims) +} + // mustMarshal is a test helper that marshals v to JSON or panics. func mustMarshal(v any) []byte { b, err := json.Marshal(v) diff --git a/internal/api/v1/routes.go b/internal/api/v1/routes.go index 7f600383..63efc3ae 100644 --- a/internal/api/v1/routes.go +++ b/internal/api/v1/routes.go @@ -92,6 +92,8 @@ func Router(svc service.RegistryService, authCfg *config.AuthConfig) http.Handle auditmw.Audited(auditmw.EventEntryPublish, auditmw.ResourceTypeEntry, "", routes.publishEntry)) r.Delete("/entries/{type}/{name}/versions/{version}", auditmw.AuditedEntry(auditmw.EventEntryDelete, routes.deletePublishedEntry)) + r.Get("/entries/{type}/{name}/claims", + auditmw.AuditedEntry(auditmw.EventEntryClaimsRead, routes.getEntryClaims)) r.Put("/entries/{type}/{name}/claims", auditmw.AuditedEntry(auditmw.EventEntryClaims, routes.updateEntryClaims)) }) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 7725b0a8..d52d6e62 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -71,6 +71,7 @@ const ( EventRegistryList = "registry.list" EventRegistryRead = "registry.read" EventRegistryEntriesList = "registry.entries.list" + EventEntryClaimsRead = "entry.claims.read" EventUserInfo = "user.info" ) diff --git a/internal/authz/authz_get_entry_claims_test.go b/internal/authz/authz_get_entry_claims_test.go new file mode 100644 index 00000000..3be05de7 --- /dev/null +++ b/internal/authz/authz_get_entry_claims_test.go @@ -0,0 +1,116 @@ +//go:build integration + +package authz_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive-registry-server/internal/config" +) + +// TestAuthzIntegration_GetEntryClaims exercises the GET /v1/entries/{type}/{name}/claims +// endpoint across roles and claim coverage: +// - manageEntries (writer) covering the entry's claims and superAdmin can read (200) +// - manageEntries (writer) NOT covering the entry's claims is denied (403) +// - admin (manageSources + manageRegistries only) is denied by role gate (403) +// - tokens with no matching role are denied (403) +// - unauthenticated requests are rejected (401) +// +//nolint:paralleltest,tparallel // subtests share state — publish first, then read +func TestAuthzIntegration_GetEntryClaims(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + env := setupEnv(t, &config.AuthConfig{ + Mode: config.AuthModeOAuth, + Authz: authzRolesConfig(), + }) + + platformWriter := env.oidc.token(t, map[string]any{"org": "acme", "team": "platform", "role": "writer"}) + dataWriter := env.oidc.token(t, map[string]any{"org": "acme", "team": "data", "role": "writer"}) + platformAdmin := env.oidc.token(t, map[string]any{"org": "acme", "team": "platform", "role": "admin"}) + superAdmin := env.oidc.token(t, map[string]any{"org": "acme", "role": "super-admin"}) + noRole := env.oidc.token(t, map[string]any{"org": "acme"}) + + waitForSync(t, env, superAdmin) + + const claimsPath = "/v1/entries/server/io.test%2Fget-claims-entry/claims" + publishedClaims := map[string]any{"org": "acme", "team": "platform"} + + t.Run("writer publishes entry", func(t *testing.T) { + resp := doRequest(t, "POST", env.baseURL+"/v1/entries", platformWriter, publishReq{ + Claims: publishedClaims, + Server: serverJSON("get-claims-entry"), + }) + assertStatus(t, resp, 201) + }) + + assertClaims := func(t *testing.T, body string, want map[string]any) { + t.Helper() + var resp struct { + Claims map[string]any `json:"claims"` + } + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + assert.Equal(t, want, resp.Claims) + } + + t.Run("manageEntries reads claims", func(t *testing.T) { + resp := doRequest(t, "GET", env.baseURL+claimsPath, platformWriter, nil) + body := assertStatus(t, resp, 200) + assertClaims(t, body, publishedClaims) + }) + + t.Run("superAdmin reads claims", func(t *testing.T) { + resp := doRequest(t, "GET", env.baseURL+claimsPath, superAdmin, nil) + body := assertStatus(t, resp, 200) + assertClaims(t, body, publishedClaims) + }) + + t.Run("cross-team writer denied", func(t *testing.T) { + // dataWriter has manageEntries role but no claim coverage for team=platform. + resp := doRequest(t, "GET", env.baseURL+claimsPath, dataWriter, nil) + assertStatus(t, resp, 403) + }) + + t.Run("manageSources/manageRegistries denied", func(t *testing.T) { + resp := doRequest(t, "GET", env.baseURL+claimsPath, platformAdmin, nil) + assertStatus(t, resp, 403) + }) + + t.Run("token with no role denied", func(t *testing.T) { + resp := doRequest(t, "GET", env.baseURL+claimsPath, noRole, nil) + assertStatus(t, resp, 403) + }) + + t.Run("unauthenticated rejected", func(t *testing.T) { + resp := doRequest(t, "GET", env.baseURL+claimsPath, "", nil) + assertStatus(t, resp, 401) + }) + + t.Run("not found returns 404", func(t *testing.T) { + resp := doRequest(t, "GET", + env.baseURL+"/v1/entries/server/io.test%2Fnon-existent/claims", + platformWriter, nil) + assertStatus(t, resp, 404) + }) + + t.Run("invalid entry type returns 400", func(t *testing.T) { + resp := doRequest(t, "GET", + env.baseURL+"/v1/entries/widget/io.test%2Fget-claims-entry/claims", + platformWriter, nil) + assertStatus(t, resp, 400) + }) + + t.Run("cleanup", func(t *testing.T) { + resp := doRequest(t, "DELETE", + env.baseURL+"/v1/entries/server/io.test%2Fget-claims-entry/versions/1.0.0", + platformWriter, nil) + assertStatus(t, resp, 204) + }) +} diff --git a/internal/service/db/get_entry_claims_test.go b/internal/service/db/get_entry_claims_test.go new file mode 100644 index 00000000..d55b9dde --- /dev/null +++ b/internal/service/db/get_entry_claims_test.go @@ -0,0 +1,279 @@ +package database + +import ( + "context" + "testing" + + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive-registry-server/internal/auth" + "github.com/stacklok/toolhive-registry-server/internal/service" +) + +// teamDataClaim is the team-name fixture used across the +// `GetEntryClaims` tests. Extracted to keep `goconst` happy when the same +// literal would otherwise repeat across files and test cases. +const teamDataClaim = "data" + +func TestGetEntryClaims_ReturnsClaims(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-claims") + + ctx := context.Background() + + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-claims", + Version: "1.0.0", + }), + service.WithClaims(map[string]any{"org": "acme", "team": "platform"}), + service.WithJWTClaims(map[string]any{"org": "acme", "team": []string{"platform", "ops"}}), + ) + require.NoError(t, err) + + claims, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/gec-claims"), + ) + require.NoError(t, err) + assert.Equal(t, map[string]any{"org": "acme", "team": "platform"}, claims) +} + +func TestGetEntryClaims_EmptyClaimsReturnsNonNilMap(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-empty") + + ctx := context.Background() + + // Publish a server with no claims (anonymous mode). + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-empty", + Version: "1.0.0", + }), + ) + require.NoError(t, err) + + claims, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/gec-empty"), + ) + require.NoError(t, err) + assert.NotNil(t, claims, "claims map must be non-nil for stable JSON shape") + assert.Empty(t, claims) +} + +func TestGetEntryClaims_ClaimsInsufficient(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-insufficient") + + ctx := context.Background() + + // Publish an entry scoped to team=data. + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-insufficient", + Version: "1.0.0", + }), + service.WithClaims(map[string]any{"org": "acme", "team": teamDataClaim}), + service.WithJWTClaims(map[string]any{"org": "acme", "team": []string{teamDataClaim}}), + ) + require.NoError(t, err) + + // A caller in team=platform must not be able to read its claims. + _, err = svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/gec-insufficient"), + service.WithJWTClaims(map[string]any{"org": "acme", "team": "platform"}), + ) + assert.ErrorIs(t, err, service.ErrClaimsInsufficient) +} + +func TestGetEntryClaims_SuperAdminBypassesSubsetCheck(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-superadmin") + + ctx := context.Background() + + // Publish an entry scoped to team=data with JWT claims that cover it. + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-superadmin", + Version: "1.0.0", + }), + service.WithClaims(map[string]any{"org": "acme", "team": teamDataClaim}), + service.WithJWTClaims(map[string]any{"org": "acme", "team": []string{teamDataClaim}}), + ) + require.NoError(t, err) + + // A super-admin caller in a completely different org must still read the + // entry's claims — the bypass is uniform across every claim check. + superAdminCtx := auth.ContextWithRoles(ctx, []auth.Role{auth.RoleSuperAdmin}) + claims, err := svc.GetEntryClaims(superAdminCtx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/gec-superadmin"), + service.WithJWTClaims(map[string]any{"org": "contoso"}), + ) + require.NoError(t, err) + assert.Equal(t, map[string]any{"org": "acme", "team": teamDataClaim}, claims) +} + +func TestGetEntryClaims_ClaimsSufficient(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-sufficient") + + ctx := context.Background() + + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-sufficient", + Version: "1.0.0", + }), + service.WithClaims(map[string]any{"org": "acme", "team": "platform"}), + service.WithJWTClaims(map[string]any{"org": "acme", "team": []string{"platform"}}), + ) + require.NoError(t, err) + + // A caller whose JWT covers the entry's claims must succeed. + claims, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/gec-sufficient"), + service.WithJWTClaims(map[string]any{"org": "acme", "team": []string{"platform", "ops"}}), + ) + require.NoError(t, err) + assert.Equal(t, map[string]any{"org": "acme", "team": "platform"}, claims) +} + +func TestGetEntryClaims_EntryNotFound(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-not-found") + + ctx := context.Background() + + _, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/nonexistent"), + ) + assert.ErrorIs(t, err, service.ErrNotFound) +} + +func TestGetEntryClaims_WrongTypeReturnsNotFound(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSource(t, svc, "gec-wrong-type") + + ctx := context.Background() + + // Publish a server, then try to fetch it as a skill. + _, err := svc.PublishServerVersion(ctx, + service.WithServerData(&upstreamv0.ServerJSON{ + Name: "com.test/gec-wrong-type", + Version: "1.0.0", + }), + service.WithClaims(map[string]any{"org": "acme"}), + ) + require.NoError(t, err) + + _, err = svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeSkill), + service.WithName("com.test/gec-wrong-type"), + ) + assert.ErrorIs(t, err, service.ErrNotFound) +} + +func TestGetEntryClaims_NoManagedSource(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + ctx := context.Background() + + _, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeServer), + service.WithName("com.test/no-managed-source"), + ) + assert.ErrorIs(t, err, service.ErrNoManagedSource) +} + +func TestGetEntryClaims_SkillType(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + createManagedSourceWithRegistry(t, svc, "gec-skill") + + ctx := context.Background() + + skill := &service.Skill{ + Namespace: "com.test", + Name: "gec-skill", + Version: "1.0.0", + Title: "Test Skill", + } + _, err := svc.PublishSkill(ctx, skill, + service.WithClaims(map[string]any{"org": "acme"}), + ) + require.NoError(t, err) + + claims, err := svc.GetEntryClaims(ctx, + service.WithEntryType(service.EntryTypeSkill), + service.WithName("gec-skill"), + ) + require.NoError(t, err) + assert.Equal(t, map[string]any{"org": "acme"}, claims) +} + +func TestGetEntryClaims_InvalidEntryType(t *testing.T) { + t.Parallel() + + svc, cleanup := setupTestService(t) + defer cleanup() + + ctx := context.Background() + + // Bypass WithEntryType (which validates) and write directly so we exercise + // the impl-side mapEntryType branch. + _, err := svc.GetEntryClaims(ctx, + func(o any) error { + opts, ok := o.(*service.GetEntryClaimsOptions) + if !ok { + return nil + } + opts.EntryType = "widget" + return nil + }, + service.WithName("anything"), + ) + assert.ErrorIs(t, err, service.ErrInvalidEntryType) +} diff --git a/internal/service/db/impl_entries.go b/internal/service/db/impl_entries.go index d43a100f..ae7d9f0f 100644 --- a/internal/service/db/impl_entries.go +++ b/internal/service/db/impl_entries.go @@ -154,3 +154,77 @@ func mapEntryType(entryType string) (sqlc.EntryType, error) { return "", fmt.Errorf("%w: %s", service.ErrInvalidEntryType, entryType) } } + +// GetEntryClaims returns the claims map for an API-published entry within the +// managed source. Synced-source entries (git/api/file/kubernetes) are out of +// scope here for two reasons: their claim source-of-truth is upstream — the +// source row's claims for git/api/file (inherited per entry) or the +// `toolhive.stacklok.dev/authz-claims` annotation for kubernetes — and every +// sync overwrites entry claims via `ON CONFLICT DO UPDATE SET claims = EXCLUDED.claims`, +// so any API write would be ephemeral. The matching PUT is managed-source-only +// for the same reason. To inspect synced-entry claims, list endpoints under +// `/v1/sources/{name}/entries` and `/v1/registries/{name}/entries` surface them. +// +// The returned map is non-nil even when the entry has no claims set, so callers +// can rely on a stable JSON shape. Access is gated by the manageEntries role +// plus a JWT-subset check against the entry's claims, mirroring the matching +// PUT and the default-deny visibility rule (auth.md §4). +func (s *dbService) GetEntryClaims(ctx context.Context, opts ...service.Option) (map[string]any, error) { + ctx, span := s.startSpan(ctx, "dbService.GetEntryClaims") + defer span.End() + + options := &service.GetEntryClaimsOptions{} + for _, opt := range opts { + if err := opt(options); err != nil { + otel.RecordError(span, err) + return nil, fmt.Errorf("invalid option: %w", err) + } + } + + span.SetAttributes( + attribute.String("entry.type", options.EntryType), + attribute.String("entry.name", options.Name), + ) + + entryType, err := mapEntryType(options.EntryType) + if err != nil { + otel.RecordError(span, err) + return nil, err + } + + querier := sqlc.New(s.pool) + + source, err := getManagedSource(ctx, querier) + if err != nil { + otel.RecordError(span, err) + return nil, err + } + + row, err := querier.GetRegistryEntryByName(ctx, sqlc.GetRegistryEntryByNameParams{ + SourceID: source.ID, + EntryType: entryType, + Name: options.Name, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", service.ErrNotFound, options.Name) + } + otel.RecordError(span, err) + return nil, fmt.Errorf("failed to look up registry entry: %w", err) + } + + gateClaims := options.JWTClaims + if s.skipAuthz { + gateClaims = nil + } + if err := validateClaimsSubsetBytes(ctx, gateClaims, row.Claims); err != nil { + otel.RecordError(span, err) + return nil, err + } + + claims := db.DeserializeClaims(row.Claims) + if claims == nil { + claims = map[string]any{} + } + return claims, nil +} diff --git a/internal/service/mocks/mock_service.go b/internal/service/mocks/mock_service.go index 60933fd6..16f8ab04 100644 --- a/internal/service/mocks/mock_service.go +++ b/internal/service/mocks/mock_service.go @@ -152,6 +152,26 @@ func (mr *MockRegistryServiceMockRecorder) DeleteSource(ctx, name any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSource", reflect.TypeOf((*MockRegistryService)(nil).DeleteSource), ctx, name) } +// GetEntryClaims mocks base method. +func (m *MockRegistryService) GetEntryClaims(ctx context.Context, opts ...service.Option) (map[string]any, error) { + m.ctrl.T.Helper() + varargs := []any{ctx} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetEntryClaims", varargs...) + ret0, _ := ret[0].(map[string]any) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEntryClaims indicates an expected call of GetEntryClaims. +func (mr *MockRegistryServiceMockRecorder) GetEntryClaims(ctx any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEntryClaims", reflect.TypeOf((*MockRegistryService)(nil).GetEntryClaims), varargs...) +} + // GetRegistryByName mocks base method. func (m *MockRegistryService) GetRegistryByName(ctx context.Context, name string) (*service.RegistryInfo, error) { m.ctrl.T.Helper() diff --git a/internal/service/options_entries.go b/internal/service/options_entries.go index 330317a4..903d99c1 100644 --- a/internal/service/options_entries.go +++ b/internal/service/options_entries.go @@ -37,3 +37,32 @@ func (o *UpdateEntryClaimsOptions) setJWTClaims(claims map[string]any) error { o.JWTClaims = claims return nil } + +// GetEntryClaimsOptions is the options for the GetEntryClaims operation. +type GetEntryClaimsOptions struct { + EntryType string // EntryTypeServer or EntryTypeSkill + Name string + JWTClaims map[string]any +} + +func (o *GetEntryClaimsOptions) setEntryType(entryType string) error { + switch entryType { + case EntryTypeServer, EntryTypeSkill: + o.EntryType = entryType + return nil + default: + return fmt.Errorf("%w: must be %q or %q", ErrInvalidEntryType, EntryTypeServer, EntryTypeSkill) + } +} + +//nolint:unparam +func (o *GetEntryClaimsOptions) setName(name string) error { + o.Name = name + return nil +} + +//nolint:unparam +func (o *GetEntryClaimsOptions) setJWTClaims(claims map[string]any) error { + o.JWTClaims = claims + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go index da02a223..ced4cca1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -154,6 +154,12 @@ type RegistryService interface { // UpdateEntryClaims updates the claims on a published entry within the managed source. UpdateEntryClaims(ctx context.Context, opts ...Option) error + + // GetEntryClaims returns the claims map for a published entry within the managed source. + // The returned map is non-nil even when the entry has no claims set. + // Returns ErrInvalidEntryType for unknown entry types, ErrNotFound when the entry + // does not exist, and ErrNoManagedSource when no managed source is configured. + GetEntryClaims(ctx context.Context, opts ...Option) (map[string]any, error) } // SourceInfo represents detailed information about a source