Skip to content
Merged
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
5 changes: 3 additions & 2 deletions deploy/lib/database-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ export class DatabaseConstruct extends Construct {
this.serviceAccountKeysTable = this.createTable({
name: "service-account-keys",
stage,
// The key's jti; the key itself is never stored.
partitionKey: "jti",
// SHA-256 of the key, which the proxy presents to look it up; the key
// itself is never stored.
partitionKey: "key_hash",
indexes: [
{
// fetch the keys of a service account
Expand Down
4 changes: 2 additions & 2 deletions scripts/init-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,10 @@ async function createTables() {
new CreateTableCommand({
TableName: getTableName("service-account-keys"),
AttributeDefinitions: [
{ AttributeName: "jti", AttributeType: "S" },
{ AttributeName: "key_hash", AttributeType: "S" },
{ AttributeName: "account_id", AttributeType: "S" },
],
KeySchema: [{ AttributeName: "jti", KeyType: "HASH" }],
KeySchema: [{ AttributeName: "key_hash", KeyType: "HASH" }],
GlobalSecondaryIndexes: [
{
IndexName: "account_id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { CONFIG } from "@/lib/config";
import { getPageSession } from "@/lib/api/utils";
import { managedServiceAccount } from "@/lib/accounts/service-accounts";
import { editAccountServiceAccountsUrl } from "@/lib/urls";
import { MembershipState } from "@/types";
import { MembershipState, publicKey } from "@/types";

export const metadata: Metadata = { title: "Service account" };

Expand Down Expand Up @@ -47,7 +47,8 @@ export default async function ServiceAccountPage({ params }: PageProps) {
account,
trusts,
grants: memberships.filter((m) => m.state === MembershipState.Member),
keys,
// The hash stays on the server; the client component sees the rest.
keys: keys.map(publicKey),
}}
products={products.map(({ product_id, title }) => ({ product_id, title }))}
proxyOrigin={CONFIG.storage.endpoint}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { getPageSession } from "@/lib/api/utils";
import { canManageAccount } from "@/lib/api/authz";
import { createServiceAccountUrl } from "@/lib/urls";
import { MembershipState, type ServiceAccountSummary } from "@/types";
import { MembershipState, publicKey, type ServiceAccountSummary } from "@/types";

export const metadata: Metadata = { title: "Service accounts" };

Expand All @@ -38,7 +38,7 @@ export default async function ServiceAccountsPage({ params }: PageProps) {
grants: (await membershipsTable.listByUser(account.account_id)).filter(
(m) => m.state === MembershipState.Member
),
keys: await serviceAccountKeysTable.listByAccount(account.account_id),
keys: (await serviceAccountKeysTable.listByAccount(account.account_id)).map(publicKey),
}))
);

Expand Down
48 changes: 0 additions & 48 deletions src/app/api/v1/service-account-keys/[jti]/exchanges/route.test.ts

This file was deleted.

54 changes: 0 additions & 54 deletions src/app/api/v1/service-account-keys/[jti]/exchanges/route.ts

This file was deleted.

74 changes: 74 additions & 0 deletions src/app/api/v1/service-account-keys/exchanges/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** @jest-environment node */
import { NextRequest } from "next/server";
import { accountsTable, serviceAccountKeysTable } from "@/lib/clients/database";
import { verifyProxyAssertion } from "@/lib/api/oidc";

jest.mock("@/lib/clients/database", () => ({
serviceAccountKeysTable: { fetchByHash: jest.fn(), set: jest.fn() },
accountsTable: { fetchById: jest.fn() },
}));
jest.mock("@/lib/api/oidc", () => ({
verifyProxyAssertion: jest.fn(),
PROXY_SELF_SUBJECT: "urn:source:data-proxy",
}));

const { POST } = require("./route");

const HASH = "a".repeat(64);
const key = { key_hash: HASH, key_id: "k1", account_id: "acme--nightly-sync", label: "HPC", expires_at: null };
const req = (body: unknown = { key_hash: HASH }) =>
new NextRequest("http://localhost/api/v1/service-account-keys/exchanges", {
method: "POST",
headers: { authorization: "Bearer proxy", "x-request-id": "r1" },
body: JSON.stringify(body),
});

describe("POST /api/v1/service-account-keys/exchanges", () => {
beforeEach(() => {
jest.resetAllMocks();
(verifyProxyAssertion as jest.Mock).mockResolvedValue({ sub: "urn:source:data-proxy" });
(serviceAccountKeysTable.fetchByHash as jest.Mock).mockResolvedValue(key);
(serviceAccountKeysTable.set as jest.Mock).mockResolvedValue(undefined);
(accountsTable.fetchById as jest.Mock).mockResolvedValue({ account_id: "acme--nightly-sync", disabled: false });
});

test("answers active with the account for a live key, and records the use", async () => {
const res = await POST(req());
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ account_id: "acme--nightly-sync", key_id: "k1", active: true });
expect(serviceAccountKeysTable.fetchByHash).toHaveBeenCalledWith(HASH);
expect(serviceAccountKeysTable.set).toHaveBeenCalledWith(HASH, "last_used_at", expect.any(String));
});

test("still answers active when recording the use fails", async () => {
(serviceAccountKeysTable.set as jest.Mock).mockRejectedValue(new Error("throttled"));
await expect((await POST(req())).json()).resolves.toMatchObject({ active: true });
});

test("answers inactive, naming no account, for a revoked, expired, disabled or unknown key, without recording a use", async () => {
(serviceAccountKeysTable.fetchByHash as jest.Mock).mockResolvedValue({ ...key, revoked_at: "2026-01-01T00:00:00Z" });
await expect((await POST(req())).json()).resolves.toEqual({ active: false });
(serviceAccountKeysTable.fetchByHash as jest.Mock).mockResolvedValue({ ...key, expires_at: "2020-01-01T00:00:00Z" });
await expect((await POST(req())).json()).resolves.toEqual({ active: false });
(serviceAccountKeysTable.fetchByHash as jest.Mock).mockResolvedValue(key);
(accountsTable.fetchById as jest.Mock).mockResolvedValue({ account_id: "acme--nightly-sync", disabled: true });
await expect((await POST(req())).json()).resolves.toEqual({ active: false });
(serviceAccountKeysTable.fetchByHash as jest.Mock).mockResolvedValue(null);
await expect((await POST(req())).json()).resolves.toEqual({ active: false });
expect(serviceAccountKeysTable.set).not.toHaveBeenCalled();
});

test("is 401 for any subject but the proxy's own, or no assertion", async () => {
(verifyProxyAssertion as jest.Mock).mockResolvedValue({ sub: "acme--nightly-sync" });
expect((await POST(req())).status).toBe(401);
(verifyProxyAssertion as jest.Mock).mockResolvedValue(null);
expect((await POST(req())).status).toBe(401);
expect(serviceAccountKeysTable.fetchByHash).not.toHaveBeenCalled();
});

test("is 400 for a body without a hex SHA-256", async () => {
expect((await POST(req({ key_hash: "sck_notahash" }))).status).toBe(400);
expect((await POST(req({}))).status).toBe(400);
expect(serviceAccountKeysTable.fetchByHash).not.toHaveBeenCalled();
});
});
82 changes: 82 additions & 0 deletions src/app/api/v1/service-account-keys/exchanges/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @openapi
* /service-account-keys/exchanges:
* post:
* tags: [Accounts]
* summary: Say whether an API key may be exchanged, and which service account it belongs to
* description: |
* Called by the data proxy at `/.sts` when an API key is presented, authenticated as the proxy itself: the key is opaque, so nothing names an account before this lookup. The proxy sends the key's SHA-256; this answers whether the key is active — known, not revoked, not expired, and its service account not disabled — and, if so, which account it is. An unknown hash is answered as inactive, indistinguishable from a revoked one. Records last use. The proxy caches the answer briefly, so revocation takes effect for new exchanges within that TTL.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [key_hash]
* properties:
* key_hash:
* type: string
* description: Hex SHA-256 of the key
* responses:
* 200:
* description: The key's standing
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
*/
import { NextRequest, NextResponse } from "next/server";
import { StatusCodes } from "http-status-codes";
import { z } from "zod";
import { PROXY_SELF_SUBJECT, verifyProxyAssertion } from "@/lib/api/oidc";
import { accountsTable, serviceAccountKeysTable } from "@/lib/clients/database";
import { LOGGER } from "@/lib/logging";
import { isKeyActive } from "@/types";

const BodySchema = z.object({ key_hash: z.string().regex(/^[0-9a-f]{64}$/) });

export async function POST(request: NextRequest) {
// Only the proxy, as itself. Never a session: the sentinel subject resolves
// to no account, and a cookie must not reach this route.
const assertion = await verifyProxyAssertion(
request.headers.get("authorization"),
new URL(request.url).origin
);
if (assertion?.sub !== PROXY_SELF_SUBJECT) {
return NextResponse.json({ error: "Unauthorized" }, { status: StatusCodes.UNAUTHORIZED });
}
const parsed = BodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "key_hash is required" }, { status: StatusCodes.BAD_REQUEST });
}

const request_id = request.headers.get("x-request-id") ?? undefined;
const key = await serviceAccountKeysTable.fetchByHash(parsed.data.key_hash);
const account = key ? await accountsTable.fetchById(key.account_id) : null;
const reason = !key
? "unknown"
: key.revoked_at
? "revoked"
: !isKeyActive(key)
? "expired"
: !account || account.disabled
? "disabled"
: null;
const meta = { request_id, key_id: key?.key_id, account_id: key?.account_id, reason };
if (!key || reason) {
LOGGER.warn("API key not exchangeable", { operation: "serviceAccountKeyExchange", metadata: meta });
return NextResponse.json({ active: false });
}

// Best-effort: a throttled write must not refuse a live key.
await serviceAccountKeysTable
.set(key.key_hash, "last_used_at", new Date().toISOString())
.catch((error: unknown) =>
LOGGER.warn("Could not record API key use", {
operation: "serviceAccountKeyExchange",
metadata: { ...meta, error: error instanceof Error ? error.message : String(error) },
})
);
LOGGER.info("API key exchanged", { operation: "serviceAccountKeyExchange", metadata: meta });
return NextResponse.json({ account_id: key.account_id, key_id: key.key_id, active: true });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ApiKeyExpiryField } from "./ApiKeyExpiryField";

/**
* How long an API key lasts, counted from now: 30 days, 90 days, a year, or
* until it is revoked. Issuing a key starts at 90 days; changing the expiry of
* a key that never expires starts at never.
*/
const meta = {
title: "Features/Service accounts/ApiKeyExpiryField",
component: ApiKeyExpiryField,
parameters: { layout: "padded" },
} satisfies Meta<typeof ApiKeyExpiryField>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = { args: { id: "expiry" } };

/** A key that already never expires. */
export const Never: Story = { args: { id: "expiry", never: true } };
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render } from "@testing-library/react";
import { Theme } from "@radix-ui/themes";
import { ApiKeyExpiryField } from "./ApiKeyExpiryField";

// jsdom has no ResizeObserver, and Radix's Select measures its trigger with one.
global.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;

// The field's one job beyond looks: submit `expires_in_days` the way the key
// actions read it. "Never" can't be an empty Select value, so it must reach the
// form as an empty string, which the actions take as no expiry.
const submitted = (ui: React.ReactElement) =>
(render(<Theme>{ui}</Theme>).container.querySelector(
'input[name="expires_in_days"]'
) as HTMLInputElement).value;

describe("ApiKeyExpiryField", () => {
it("submits 90 days by default", () => {
expect(submitted(<ApiKeyExpiryField id="expiry" />)).toBe("90");
});

it("submits an empty value for a key that never expires", () => {
expect(submitted(<ApiKeyExpiryField id="expiry" never />)).toBe("");
});
});
Loading
Loading