diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index 18758fe0..3cbf02e1 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 de277782..cf8d82bc 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 f8fbb404..168a978f 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 969cd88a..723f99d0 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 2393c69e..628af0d9 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) })