Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions console/src/oapi/management.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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?: {
Expand Down
41 changes: 41 additions & 0 deletions internal/http/controllers/v1/management/auth_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions internal/http/controllers/v1/management/oapi/resources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading