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: 5 additions & 0 deletions .changeset/workspace-governance-scope-widening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/uploads": patch
---

Widen the token-mint scope types to accept `"workspace:invite"` and `"workspace:manage"` alongside the existing file and operator scopes, so CLI/SDK callers can request org-admin-gated workspace-governance scopes minted via `POST /v1/tokens` (#262). No new commands or flags.
72 changes: 72 additions & 0 deletions apps/api/src/auth-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
isWorkspaceScope,
parseScopes,
validateScopes,
WORKSPACE_SCOPES,
type FileScope,
} from "./auth-db";

const DEFAULTS: FileScope[] = ["files:read", "files:write"];

describe("isWorkspaceScope (#262)", () => {
it("recognizes workspace:invite and workspace:manage", () => {
for (const scope of WORKSPACE_SCOPES) {
expect(isWorkspaceScope(scope)).toBe(true);
}
});

it("rejects unrelated strings", () => {
expect(isWorkspaceScope("files:read")).toBe(false);
expect(isWorkspaceScope("operator:read")).toBe(false);
expect(isWorkspaceScope("workspace:nuke")).toBe(false);
});
});

describe("validateScopes allowWorkspace gating (#262)", () => {
it("rejects workspace:* scopes when allowWorkspace is omitted (existing callers unchanged)", () => {
expect(validateScopes(["workspace:invite"], DEFAULTS)).toBeNull();
});

it("rejects workspace:* scopes when allowWorkspace is false", () => {
expect(validateScopes(["workspace:invite"], DEFAULTS, { allowWorkspace: false })).toBeNull();
});

it("accepts workspace:* scopes when allowWorkspace is true", () => {
expect(
validateScopes(["files:read", "workspace:invite"], DEFAULTS, { allowWorkspace: true }),
).toEqual(["files:read", "workspace:invite"]);
});

it("requires both gates for a mixed operator:* + workspace:* request", () => {
// Only allowOperator set -> workspace:* still rejected.
expect(
validateScopes(["operator:read", "workspace:invite"], DEFAULTS, { allowOperator: true }),
).toBeNull();
// Only allowWorkspace set -> operator:* still rejected.
expect(
validateScopes(["operator:read", "workspace:invite"], DEFAULTS, { allowWorkspace: true }),
).toBeNull();
// Both set -> accepted.
expect(
validateScopes(["operator:read", "workspace:invite"], DEFAULTS, {
allowOperator: true,
allowWorkspace: true,
}),
).toEqual(["operator:read", "workspace:invite"]);
});
});

describe("parseScopes fail-closed for governance scopes (#262)", () => {
it("rejects an array containing a workspace:* scope — a governance token has zero file access", () => {
expect(parseScopes(JSON.stringify(["workspace:invite"]))).toEqual([]);
});

it("rejects a mixed file + workspace:* array (no partial file access)", () => {
expect(parseScopes(JSON.stringify(["files:read", "workspace:invite"]))).toEqual([]);
});

it("still parses plain file scopes", () => {
expect(parseScopes(JSON.stringify(["files:read"]))).toEqual(["files:read"]);
});
});
37 changes: 26 additions & 11 deletions apps/api/src/auth-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export function isOperatorScope(value: unknown): value is OperatorScope {
return typeof value === "string" && OPERATOR_SCOPES.includes(value as OperatorScope);
}

// Workspace-governance scopes for session-minted tokens (issue #262). Like
// OPERATOR_SCOPES, never granted by default — only when explicitly requested
// by a session user holding org role `admin`/`owner` in the target workspace
// (see routes/tokens.ts mint handler). A `workspace:*` token authorizes
// governance actions (e.g. inviting members) only on the workspace embedded
// in the token; it carries zero file access (parseScopes rejects it outright).
export const WORKSPACE_SCOPES = ["workspace:invite", "workspace:manage"] as const;
export type WorkspaceScope = (typeof WORKSPACE_SCOPES)[number];

export function isWorkspaceScope(value: unknown): value is WorkspaceScope {
return typeof value === "string" && WORKSPACE_SCOPES.includes(value as WorkspaceScope);
}

// Invite code lifetime. 2h gives a human time to onboard after receiving the
// link out-of-band, while keeping the single-use secret short-lived. Override
// per-invite with --expires-in up to MAX_ENROLLMENT_SECONDS (see routes/admin).
Expand Down Expand Up @@ -67,20 +80,21 @@ export function validateScopes(value: unknown, defaults: FileScope[]): FileScope
export function validateScopes(
value: unknown,
defaults: FileScope[],
opts: { allowOperator?: boolean },
): (FileScope | OperatorScope)[] | null;
opts: { allowOperator?: boolean; allowWorkspace?: boolean },
): (FileScope | OperatorScope | WorkspaceScope)[] | null;
export function validateScopes(
value: unknown,
defaults: FileScope[],
opts?: { allowOperator?: boolean },
): (FileScope | OperatorScope)[] | null {
opts?: { allowOperator?: boolean; allowWorkspace?: boolean },
): (FileScope | OperatorScope | WorkspaceScope)[] | null {
if (value === undefined) return defaults;
if (!Array.isArray(value) || value.length === 0) return null;
const isValid = opts?.allowOperator
? (v: unknown) => isFileScope(v) || isOperatorScope(v)
: isFileScope;
const isValid = (v: unknown) =>
isFileScope(v) ||
(opts?.allowOperator === true && isOperatorScope(v)) ||
(opts?.allowWorkspace === true && isWorkspaceScope(v));
if (!value.every(isValid)) return null;
return [...new Set(value)] as (FileScope | OperatorScope)[];
return [...new Set(value)] as (FileScope | OperatorScope | WorkspaceScope)[];
}

function randomSecret(prefix: string, bytes = 24): string {
Expand Down Expand Up @@ -120,9 +134,10 @@ export async function createToken(
input: {
workspace: string;
label?: string;
// Widened beyond FileScope so admin-scoped operator tokens (issue #257)
// can be minted through the same path; storage is just a JSON TEXT column.
scopes: (FileScope | OperatorScope)[];
// Widened beyond FileScope so admin-scoped operator tokens (issue #257) and
// workspace-governance tokens (issue #262) can be minted through the same
// path; storage is just a JSON TEXT column.
scopes: (FileScope | OperatorScope | WorkspaceScope)[];
expiresAt?: Date;
mintedByUserId?: string | null;
now?: Date;
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/routes/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,16 @@ function requireUserId(c: Context<SessionVars>): string {
return userId;
}

/** Membership admin|owner for this workspace — 404 if not a member, 403 if member but not privileged. */
async function adminWorkspaceOr403(env: Env, userId: string, name: string): Promise<MyWorkspace> {
/**
* Membership admin|owner for this workspace — 404 if not a member, 403 if
* member but not privileged. Exported for reuse by the self-serve token
* governance dual-auth guard (`workspaces.ts`, issue #262 Task 3).
*/
export async function adminWorkspaceOr403(
env: Env,
userId: string,
name: string,
): Promise<MyWorkspace> {
const ws = await memberWorkspaceOr404(env, userId, name);
if (ws.role !== "admin" && ws.role !== "owner") {
throw new ForbiddenError("workspace admin or owner role required", {
Expand Down
99 changes: 99 additions & 0 deletions apps/api/src/routes/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,102 @@ describe("POST /v1/tokens operator scopes", () => {
expect(body.scopes).toEqual(["files:read", "files:write"]);
});
});

describe("POST /v1/tokens workspace-governance scopes (#262)", () => {
it("400s when an org member (not admin/owner) requests workspace:invite", async () => {
const res = await post(
stubEnv({
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "member" }],
}),
{ grants: [{ workspace: "acme", scopes: ["workspace:invite"] }] },
);
expect(res.status).toBe(400);
expect((await res.json()) as { error: { code: string } }).toMatchObject({
error: { code: "invalid_scopes" },
});
});

it("201s when the caller is an org admin in the target workspace", async () => {
const res = await post(
stubEnv({
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "admin" }],
}),
{ grants: [{ workspace: "acme", scopes: ["workspace:invite"] }] },
);
expect(res.status).toBe(201);
const body = (await res.json()) as { scopes: string[] };
expect(body.scopes).toEqual(["workspace:invite"]);
});

it("201s when the caller is an org owner in the target workspace", async () => {
const res = await post(
stubEnv({
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "owner" }],
}),
{ grants: [{ workspace: "acme", scopes: ["workspace:manage"] }] },
);
expect(res.status).toBe(201);
const body = (await res.json()) as { scopes: string[] };
expect(body.scopes).toEqual(["workspace:manage"]);
});

it("400s for a platform admin without an org admin/owner role in the workspace — no bypass", async () => {
const res = await post(
stubEnv({
user: { ...USER, role: "admin" },
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "member" }],
}),
{ grants: [{ workspace: "acme", scopes: ["workspace:invite"] }] },
);
expect(res.status).toBe(400);
expect((await res.json()) as { error: { code: string } }).toMatchObject({
error: { code: "invalid_scopes" },
});
});

it("never includes workspace scopes in the default mint, even for an org owner", async () => {
const res = await post(
stubEnv({
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "owner" }],
}),
{ grants: [{ workspace: "acme" }] },
);
expect(res.status).toBe(201);
const body = (await res.json()) as { scopes: string[] };
expect(body.scopes).toEqual(["files:read", "files:write"]);
});

it("requires both gates for a mixed operator:* + workspace:* request", async () => {
// Org owner (workspace gate passes) but non-admin platform role (operator gate fails).
const failsOperatorGate = await post(
stubEnv({
user: { ...USER, role: "user" },
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "owner" }],
}),
{ grants: [{ workspace: "acme", scopes: ["operator:read", "workspace:invite"] }] },
);
expect(failsOperatorGate.status).toBe(400);

// Platform admin (operator gate passes) but plain member (workspace gate fails).
const failsWorkspaceGate = await post(
stubEnv({
user: { ...USER, role: "admin" },
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "member" }],
}),
{ grants: [{ workspace: "acme", scopes: ["operator:read", "workspace:invite"] }] },
);
expect(failsWorkspaceGate.status).toBe(400);

// Both gates pass.
const bothPass = await post(
stubEnv({
user: { ...USER, role: "admin" },
memberships: [{ organizationId: ORG.id, organizationSlug: ORG.slug, role: "owner" }],
}),
{ grants: [{ workspace: "acme", scopes: ["operator:read", "workspace:invite"] }] },
);
expect(bothPass.status).toBe(201);
const body = (await bothPass.json()) as { scopes: string[] };
expect(body.scopes).toEqual(["operator:read", "workspace:invite"]);
});
});
63 changes: 34 additions & 29 deletions apps/api/src/routes/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
validateScopes,
DEFAULT_TOKEN_SECONDS,
MAX_TOKEN_SECONDS,
type OperatorScope,
type FileScope,
} from "../auth-db";
import { allowWrite } from "../guards";
Expand All @@ -41,26 +40,24 @@ const MAX_LABEL_LEN = 200;
// the minting user's admin role (see the mint handler below).
const DEFAULT_MINT_SCOPES: FileScope[] = ["files:read", "files:write"];

interface Grant {
interface RawGrant {
workspace: string;
scopes: (FileScope | OperatorScope)[];
rawScopes: unknown;
}

/**
* Validate the request body into a single normalized grant + label/ttl.
* Throws ValidationError (400) on any malformed input. `grants` is an array by
* contract, but v1 permits exactly one entry.
* Parse the request body into a normalized (but not-yet-scope-validated)
* grant + label/ttl. Throws ValidationError (400) on any malformed input.
* `grants` is an array by contract, but v1 permits exactly one entry.
*
* `allowOperator` gates whether operator:* scopes are accepted at all — callers
* pass this only when the session user holds the admin role, so a non-admin
* requesting an operator scope fails the same `invalid_scopes` validation as any
* other unknown scope (issue #257 spec), not a distinct 403.
* Scope validation is deferred to the caller (see the mint handler below):
* whether operator:* / workspace:* scopes are acceptable depends on the
* session user's admin role (operator) and, for workspace:* scopes, on the
* caller's org role in the *target workspace* — which this function doesn't
* resolve. That keeps this parser a pure structural check.
*/
function parseMintRequest(
parsed: unknown,
opts: { allowOperator: boolean },
): {
grant: Grant;
function parseMintRequest(parsed: unknown): {
grant: RawGrant;
label?: string;
ttlSeconds: number;
} {
Expand Down Expand Up @@ -90,13 +87,6 @@ function parseMintRequest(
if (!WS_NAME_RE.test(workspace)) {
throw new ValidationError("grant.workspace is invalid", { code: "invalid_workspace" });
}
const scopes = validateScopes(grantObj.scopes, DEFAULT_MINT_SCOPES, {
allowOperator: opts.allowOperator,
});
if (scopes === null) {
throw new ValidationError("grant.scopes contains an unknown scope", { code: "invalid_scopes" });
}

let label: string | undefined;
if (body.label !== undefined) {
if (typeof body.label !== "string") {
Expand Down Expand Up @@ -129,7 +119,7 @@ function parseMintRequest(
ttlSeconds = body.ttlSeconds;
}

return { grant: { workspace, scopes }, label, ttlSeconds };
return { grant: { workspace, rawScopes: grantObj.scopes }, label, ttlSeconds };
}

export const tokens = new Hono<SessionVars>()
Expand Down Expand Up @@ -169,9 +159,7 @@ export const tokens = new Hono<SessionVars>()

// requireSessionUser guarantees this is set.
const user = c.get("sessionUser")!;
const { grant, label, ttlSeconds } = parseMintRequest(parsed, {
allowOperator: userHasAdminRole(user),
});
const { grant, label, ttlSeconds } = parseMintRequest(parsed);

// Three independent lookups — resolve them concurrently, then gate. The
// workspace must exist as a KV tenant record (a token is meaningless
Expand All @@ -184,10 +172,27 @@ export const tokens = new Hono<SessionVars>()
orgForWorkspace(c.env, grant.workspace),
membershipsForUser(c.env, user.id),
]);
if (!record || !org || !memberships.some((m) => m.organizationId === org.id)) {
const membership = org ? memberships.find((m) => m.organizationId === org.id) : undefined;
if (!record || !org || !membership) {
throw new ForbiddenError("no access to this workspace", { code: "workspace_forbidden" });
}

// workspace:* scopes require the caller's org role in THIS workspace to be
// admin/owner (string check matching adminWorkspaceOr403 in routes/me.ts).
// Platform-admin role does NOT bypass this — operators already have
// /admin-ui, so a platform admin without an org role here still fails
// invalid_scopes, same as any other unauthorized scope request (#262).
const allowWorkspace = membership.role === "admin" || membership.role === "owner";
const scopes = validateScopes(grant.rawScopes, DEFAULT_MINT_SCOPES, {
allowOperator: userHasAdminRole(user),
allowWorkspace,
});
if (scopes === null) {
throw new ValidationError("grant.scopes contains an unknown scope", {
code: "invalid_scopes",
});
}

// Throttle minting per workspace — checked only after the membership gate,
// so a non-member can't burn a workspace's mint budget by griefing this
// endpoint. Same WRITE_LIMITER other mutating routes use (see guards.ts).
Expand All @@ -199,7 +204,7 @@ export const tokens = new Hono<SessionVars>()
const { token, record: tokenRecord } = await createToken(c.env.DB, {
workspace: grant.workspace,
label,
scopes: grant.scopes,
scopes,
expiresAt,
mintedByUserId: user.id,
});
Expand All @@ -208,7 +213,7 @@ export const tokens = new Hono<SessionVars>()
{
token,
workspace: grant.workspace,
scopes: grant.scopes,
scopes,
label: tokenRecord.label,
expiresAt: tokenRecord.expires_at,
},
Expand Down
Loading
Loading