From 553d831685d8c23b9306ff70a4c3b1572168c016 Mon Sep 17 00:00:00 2001 From: IAmKirbki Date: Wed, 14 Oct 2026 15:52:28 +0200 Subject: [PATCH] feat(management): let an API key look up its own project Add GET /api/admin/auth/key, which returns the calling API key's id, name, project, organization and role. Every other management endpoint either names the project in its URL or is reserved for admins, so a caller holding only a key had no way to discover which project to address. The key learns only about itself, so no permission check is needed beyond authentication. Admins and other actors are refused with a 403. The role is read back from the auth method rather than the actor, which does not carry it; a key revoked since the auth cache was filled gets a 404. Co-Authored-By: Claude Opus 5.5 --- console/src/oapi/management.generated.ts | 57 +++++++ .../controllers/v1/management/auth_methods.go | 41 +++++ .../management/auth_methods_handlers_test.go | 51 ++++++ .../v1/management/oapi/resources.yml | 49 ++++++ .../v1/management/oapi/resources_gen.go | 161 ++++++++++++++++++ 5 files changed, 359 insertions(+) diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index 18758fe0e..3cbf02e1e 100644 --- a/console/src/oapi/management.generated.ts +++ b/console/src/oapi/management.generated.ts @@ -2130,6 +2130,30 @@ export interface paths { patch: operations["updateProjectAdmin"]; trace?: never; }; + "/api/admin/auth/key": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the calling API key + * @description Returns the identity of the API key that authenticated the request: the + * project and organization it is scoped to, and its role. A key learns + * nothing beyond the project it already has access to, so an integration + * holding only a key can discover which project to address. Only API keys + * may call this endpoint; any other caller is refused. + */ + get: operations["getCurrentAPIKey"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/projects/{projectID}/auth-methods": { parameters: { query?: never; @@ -4872,6 +4896,18 @@ export interface components { /** Format: date-time */ updated_at: string; }; + /** @description The API key that authenticated the request. */ + CurrentAPIKey: { + /** Format: uuid */ + id: string; + /** @example Production key */ + name: string; + /** Format: uuid */ + project_id: string; + /** Format: uuid */ + organization_id: string; + role: components["schemas"]["ProjectRole"]; + }; CreateAuthMethod: { type: components["schemas"]["AuthMethodType"]; name: string; @@ -9776,6 +9812,27 @@ export interface operations { default: components["responses"]["Error"]; }; }; + getCurrentAPIKey: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The calling API key */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CurrentAPIKey"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; listAuthMethods: { parameters: { query?: { diff --git a/internal/http/controllers/v1/management/auth_methods.go b/internal/http/controllers/v1/management/auth_methods.go index de277782d..cf8d82bc5 100644 --- a/internal/http/controllers/v1/management/auth_methods.go +++ b/internal/http/controllers/v1/management/auth_methods.go @@ -118,6 +118,47 @@ func (srv *AuthMethodsController) GetAuthMethod(w http.ResponseWriter, r *http.R json.Write(w, http.StatusOK, toOAPIAuthMethod(method, false)) } +// GetCurrentAPIKey tells an API key which project and organization it is scoped +// to, and its role. Every other management endpoint either names the project in +// its URL or is reserved for admins, so without this a caller holding only a +// key has no way to learn the project it should address. +// +// It needs no permission check beyond authentication: the key is told only +// about itself, which is the one project it can already act on. The method is +// read back through the key's own project, so a key revoked since the +// authentication cache was filled is answered with a 404. +func (srv *AuthMethodsController) GetCurrentAPIKey(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + actor := rbac.FromContext(ctx) + if actor == nil || actor.ID == "" { + oapi.WriteProblem(w, problem.ErrUnauthorized()) + return + } + if actor.Type != rbac.ActorAPIKey { + oapi.WriteProblem(w, problem.ErrForbidden(problem.Describe("this endpoint is only available to api keys"))) + return + } + + methodID, err := uuid.Parse(actor.ID) + if err != nil { + oapi.WriteProblem(w, problem.ErrUnauthorized()) + return + } + + method := srv.fetch(ctx, w, actor.ProjectID, methodID) + if method == nil { + return + } + + json.Write(w, http.StatusOK, oapi.CurrentAPIKey{ + Id: method.ID, + Name: method.Name, + ProjectId: method.ProjectID, + OrganizationId: actor.OrganizationID, + Role: oapi.ProjectRole(method.Role), + }) +} + func (srv *AuthMethodsController) UpdateAuthMethod(w http.ResponseWriter, r *http.Request, projectID, methodID uuid.UUID) { ctx := r.Context() if !srv.authorizeProject(ctx, w, projectID, rbac.Update) { diff --git a/internal/http/controllers/v1/management/auth_methods_handlers_test.go b/internal/http/controllers/v1/management/auth_methods_handlers_test.go index f8fbb4041..168a978fd 100644 --- a/internal/http/controllers/v1/management/auth_methods_handlers_test.go +++ b/internal/http/controllers/v1/management/auth_methods_handlers_test.go @@ -266,6 +266,57 @@ func TestGetAuthMethodHandler(t *testing.T) { }) } +// --- Current API key -------------------------------------------------------- + +func TestGetCurrentAPIKeyHandler(t *testing.T) { + t.Parallel() + + f, ctx := setupAuthMethodsController(t, "admin") + method := f.createAPIKey(t, ctx, "editor") + + // keyCtx mirrors the actor auth.WithKey attaches for an authenticated key. + keyCtx := func(id uuid.UUID) context.Context { + return rbac.WithActor(ctx, rbac.NewActor(rbac.ActorAPIKey, id.String(), + rbac.WithOrganizationID(f.orgID), + rbac.WithProjectID(f.projectID), + )) + } + + t.Run("api key", func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/auth/key", nil) + req = req.WithContext(keyCtx(method.ID)) + f.controller.GetCurrentAPIKey(res, req) + + require.Equal(t, 200, res.Code, res.Body.String()) + var out oapi.CurrentAPIKey + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &out)) + assert.Equal(t, method.ID, out.Id) + assert.Equal(t, method.Name, out.Name) + assert.Equal(t, f.projectID, out.ProjectId) + assert.Equal(t, f.orgID, out.OrganizationId) + assert.Equal(t, oapi.ProjectRoleEditor, out.Role) + }) + + t.Run("admin is refused", func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/auth/key", nil) + req = req.WithContext(ctx) + f.controller.GetCurrentAPIKey(res, req) + + require.Equal(t, 403, res.Code, res.Body.String()) + }) + + t.Run("revoked key", func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/auth/key", nil) + req = req.WithContext(keyCtx(uuid.New())) + f.controller.GetCurrentAPIKey(res, req) + + require.Equal(t, 404, res.Code, res.Body.String()) + }) +} + // --- Update ----------------------------------------------------------------- func TestUpdateAuthMethodHandler(t *testing.T) { diff --git a/internal/http/controllers/v1/management/oapi/resources.yml b/internal/http/controllers/v1/management/oapi/resources.yml index 969cd88ac..723f99d04 100644 --- a/internal/http/controllers/v1/management/oapi/resources.yml +++ b/internal/http/controllers/v1/management/oapi/resources.yml @@ -5008,6 +5008,30 @@ paths: default: $ref: "#/components/responses/Error" + /api/admin/auth/key: + get: + summary: Get the calling API key + description: | + Returns the identity of the API key that authenticated the request: the + project and organization it is scoped to, and its role. A key learns + nothing beyond the project it already has access to, so an integration + holding only a key can discover which project to address. Only API keys + may call this endpoint; any other caller is refused. + operationId: getCurrentAPIKey + tags: + - Auth Methods + security: + - HttpBearerAuth: [] + responses: + "200": + description: The calling API key + content: + application/json: + schema: + $ref: "#/components/schemas/CurrentAPIKey" + default: + $ref: "#/components/responses/Error" + /api/admin/projects/{projectID}/auth-methods: get: summary: List auth methods @@ -9793,6 +9817,31 @@ components: type: string format: date-time + CurrentAPIKey: + type: object + description: The API key that authenticated the request. + required: + - id + - name + - project_id + - organization_id + - role + properties: + id: + type: string + format: uuid + name: + type: string + example: "Production key" + project_id: + type: string + format: uuid + organization_id: + type: string + format: uuid + role: + $ref: "#/components/schemas/ProjectRole" + CreateAuthMethod: type: object required: diff --git a/internal/http/controllers/v1/management/oapi/resources_gen.go b/internal/http/controllers/v1/management/oapi/resources_gen.go index 2393c69ef..628af0d94 100644 --- a/internal/http/controllers/v1/management/oapi/resources_gen.go +++ b/internal/http/controllers/v1/management/oapi/resources_gen.go @@ -1265,6 +1265,17 @@ type CreateUserEventRequest struct { Name string `json:"name"` } +// CurrentAPIKey The API key that authenticated the request. +type CurrentAPIKey struct { + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + OrganizationId openapi_types.UUID `json:"organization_id"` + ProjectId openapi_types.UUID `json:"project_id"` + + // Role Role within a project + Role ProjectRole `json:"role"` +} + // Delivery defines model for Delivery. type Delivery struct { Clicks int `json:"clicks"` @@ -3475,6 +3486,9 @@ type ClientInterface interface { SetActiveOrganization(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetCurrentAPIKey request + GetCurrentAPIKey(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListMyOrganizations request ListMyOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4182,6 +4196,18 @@ func (c *Client) SetActiveOrganization(ctx context.Context, body SetActiveOrgani return c.Client.Do(req) } +func (c *Client) GetCurrentAPIKey(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCurrentAPIKeyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListMyOrganizations(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListMyOrganizationsRequest(c.Server) if err != nil { @@ -7186,6 +7212,33 @@ func NewSetActiveOrganizationRequestWithBody(server string, contentType string, return req, nil } +// NewGetCurrentAPIKeyRequest generates requests for GetCurrentAPIKey +func NewGetCurrentAPIKeyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/admin/auth/key") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListMyOrganizationsRequest generates requests for ListMyOrganizations func NewListMyOrganizationsRequest(server string) (*http.Request, error) { var err error @@ -17274,6 +17327,9 @@ type ClientWithResponsesInterface interface { SetActiveOrganizationWithResponse(ctx context.Context, body SetActiveOrganizationJSONRequestBody, reqEditors ...RequestEditorFn) (*SetActiveOrganizationResponse, error) + // GetCurrentAPIKeyWithResponse request + GetCurrentAPIKeyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetCurrentAPIKeyResponse, error) + // ListMyOrganizationsWithResponse request ListMyOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListMyOrganizationsResponse, error) @@ -17987,6 +18043,37 @@ func (r SetActiveOrganizationResponse) ContentType() string { return "" } +type GetCurrentAPIKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CurrentAPIKey + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r GetCurrentAPIKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetCurrentAPIKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetCurrentAPIKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListMyOrganizationsResponse struct { Body []byte HTTPResponse *http.Response @@ -23847,6 +23934,15 @@ func (c *ClientWithResponses) SetActiveOrganizationWithResponse(ctx context.Cont return ParseSetActiveOrganizationResponse(rsp) } +// GetCurrentAPIKeyWithResponse request returning *GetCurrentAPIKeyResponse +func (c *ClientWithResponses) GetCurrentAPIKeyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetCurrentAPIKeyResponse, error) { + rsp, err := c.GetCurrentAPIKey(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetCurrentAPIKeyResponse(rsp) +} + // ListMyOrganizationsWithResponse request returning *ListMyOrganizationsResponse func (c *ClientWithResponses) ListMyOrganizationsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListMyOrganizationsResponse, error) { rsp, err := c.ListMyOrganizations(ctx, reqEditors...) @@ -26037,6 +26133,39 @@ func ParseSetActiveOrganizationResponse(rsp *http.Response) (*SetActiveOrganizat return response, nil } +// ParseGetCurrentAPIKeyResponse parses an HTTP response from a GetCurrentAPIKeyWithResponse call +func ParseGetCurrentAPIKeyResponse(rsp *http.Response) (*GetCurrentAPIKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetCurrentAPIKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CurrentAPIKey + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + // ParseListMyOrganizationsResponse parses an HTTP response from a ListMyOrganizationsWithResponse call func ParseListMyOrganizationsResponse(rsp *http.Response) (*ListMyOrganizationsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -31937,6 +32066,9 @@ type ServerInterface interface { // Set active organization // (POST /api/admin/active-organization) SetActiveOrganization(w http.ResponseWriter, r *http.Request) + // Get the calling API key + // (GET /api/admin/auth/key) + GetCurrentAPIKey(w http.ResponseWriter, r *http.Request) // List my organizations // (GET /api/admin/organizations) ListMyOrganizations(w http.ResponseWriter, r *http.Request) @@ -32513,6 +32645,12 @@ func (_ Unimplemented) SetActiveOrganization(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNotImplemented) } +// Get the calling API key +// (GET /api/admin/auth/key) +func (_ Unimplemented) GetCurrentAPIKey(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // List my organizations // (GET /api/admin/organizations) func (_ Unimplemented) ListMyOrganizations(w http.ResponseWriter, r *http.Request) { @@ -33670,6 +33808,26 @@ func (siw *ServerInterfaceWrapper) SetActiveOrganization(w http.ResponseWriter, handler.ServeHTTP(w, r) } +// GetCurrentAPIKey operation middleware +func (siw *ServerInterfaceWrapper) GetCurrentAPIKey(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, HttpBearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCurrentAPIKey(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListMyOrganizations operation middleware func (siw *ServerInterfaceWrapper) ListMyOrganizations(w http.ResponseWriter, r *http.Request) { @@ -42523,6 +42681,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/admin/active-organization", wrapper.SetActiveOrganization) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/admin/auth/key", wrapper.GetCurrentAPIKey) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/admin/organizations", wrapper.ListMyOrganizations) })