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
2 changes: 1 addition & 1 deletion apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const HubEnv = type({
"dev/test-only opt-in to boot without CREDENTIAL_ENCRYPTION_KEY or PRINCIPAL_KEY_ENCRYPTION_KEY, storing secrets and signing keys at rest unencrypted with a boot warning; refused unless BASE_URL is a loopback address, so a real deployment can never inherit it by accident",
),
"ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe(
"dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks and pending-invite redemption normally require emailVerified; never set this for a real deployment",
"dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks normally require emailVerified; never set this for a real deployment",
),
"HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe(
"opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch",
Expand Down
27 changes: 6 additions & 21 deletions docs/TENANCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,15 @@ is never patched into a vendor route. Two layers, in order:
domains"` (with an `allowedDomains` list), or `"open"`. An absent row
is closed defaults, identical in effect to `selfSignup: "off"`.

**closed** — self-serve email signup is rejected. An owner adds members
via the native invite/membership path, shares a **copy-link invite**
(token in the URL, out of scope for delivery), or pre-vets an email (or
a whole domain) as a **pending invite** — see below — before that person
ever logs in.
**closed** — self-serve email signup is rejected. New humans join only
when an operator creates them through native APIs, or an owner shares a
**copy-link invite** (token in the URL, out of scope for delivery).

**Email must be verified.** better-auth is configured without
`requireEmailVerification`, so a freshly-registered address is not
proof of ownership on its own. Every email-trust decision
`@workbench/access-policy` makes — an allowed-domains match, an open-
policy pass, a pending-invite redemption — requires
`@workbench/access-policy` makes — an allowed-domains match or an
open-policy pass — requires
`user.emailVerified === true`; an unverified email is denied, fail-
closed, regardless of what the policy or env otherwise allow.
`ALLOW_UNVERIFIED_EMAILS=1` opts out for local dev/test only, mirroring
Expand All @@ -100,19 +98,6 @@ row flip the env switch automatically — the env switch is an operator
deployment fact, the policy row is a per-bench product setting, and the
mismatch is meant to be visible, not auto-resolved.

### Pending invites (the not-yet-registered-user bridge)

The native invite route (`POST /tenants/:id/members/invite`) requires an
existing `user` row looked up by email — it cannot invite someone who
has never signed in. `@workbench/access-policy` bridges that gap with
its own `pending_invite` table: an admin records an email (or a domain,
for a standing "anyone at this domain may join" rule) against a tenant
before that person has an account. On that email's first login, the
onboarding hook resolves the match, redeems it through the native invite
route (now that a user row exists) and an immediate activation, and
consumes an exact-email match (a domain match is a standing rule and is
never consumed).

### Workbench icon

Product metadata per tenant: monogram (1–2 characters) + color token.
Expand Down Expand Up @@ -329,7 +314,7 @@ needs a weaker role, that is an Interchange conversation first.
- `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts
- `@workbench/onboarding` — personal bench provision under operator parent
- `@workbench/access-policy` — closed-by-default signup/sub-workbench-
creation policy, pending invites (CL-5886)
creation policy
- `apps/hub` — `WORKBENCH_SIGNUP`, invite routes, icon routes; one of the
explicitly-listed apps/hub mounts pending extraction into a package (see
[ARCHITECTURE.md](../ARCHITECTURE.md), CL-6127)
23 changes: 10 additions & 13 deletions packages/access-policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,31 @@

Closed-by-default access policy for the hub: per-tenant self-signup and
sub-workbench creation rules, layered over Interchange's native
tenancy/RBAC without patching vendor routes. A pending invite resolves
through the native invite route (`POST /tenants/:id/members/invite`) plus
an immediate status flip to `"active"`, the same two primitives
`packages/settings-ui` already drives by hand — this package only decides
whether those calls are allowed to happen.
tenancy/RBAC without patching vendor routes. Signup stays closed unless
an explicit policy row or env flag opens it. New humans join only when
an operator creates them through native APIs.

## Composition over Interchange

- No parallel tenancy or RBAC model: tenant creation, invites, and role
checks all go through native `@intx/hub-api` routes and grants.
- No parallel tenancy or RBAC model: tenant creation and role checks
all go through native `@intx/hub-api` routes and grants.
- `policy.ts` is the pure evaluation core (no DB, no HTTP, no env) —
every decision (can this email self-sign-up, can this role create a
sub-workbench) reduces to a function call over plain data.
- `gate.ts` composes `policy.ts` with `store.ts` for the two entry points
`packages/onboarding`'s first-login hook calls.
- `gate.ts` composes `policy.ts` with `store.ts` for the signup-gate
entry point `packages/onboarding`'s first-login hook calls.

## Key modules

- `policy.ts` — pure decision functions: `resolveAccessPolicy`,
`domainAllowed`, `evaluateSignupGate`, `canCreateTenancy`.
- `gate.ts` — composes policy + store for the onboarding first-login hook.
- `routes.ts` — tenant-scoped HTTP surface: read/edit a tenant's own
policy row, manage pending invites, and the gated
`POST .../child-tenants` surface.
policy row and the gated `POST .../child-tenants` surface.
- `store.ts` — Postgres-backed persistence plus an in-memory fake for
tests that don't need a real database.
- `schema.ts` — the two product tables (`policy`, `pending_invite`),
siloed in their own `access_policy` Postgres schema, never `public`.
- `schema.ts` — the product table (`policy`), siloed in its own
`access_policy` Postgres schema, never `public`.
- `migrations.ts` — package-owned migrations with their own ledger table,
so the package can be extracted without disentangling platform history.
- `types.ts` — arktype shapes for anything crossing a trust boundary
Expand Down
223 changes: 1 addition & 222 deletions packages/access-policy/src/gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { ApiCall, ApiResult } from "@corbits/hub-api-client";

import { checkSignupGate, resolvePendingInviteOnLogin } from "./gate";
import { checkSignupGate } from "./gate";
import { createInMemoryAccessPolicyStore } from "./store";

const verified = { emailVerified: true, allowUnverifiedEmails: false };
Expand Down Expand Up @@ -104,223 +103,3 @@ describe("checkSignupGate", () => {
expect(result).toEqual({ allowed: true, reason: "policy_open" });
});
});

function fakeApi(
handler: (method: string, path: string, body: unknown) => ApiResult,
): ApiCall {
return async (method, path, body) => handler(method, path, body);
}

describe("resolvePendingInviteOnLogin", () => {
test("no matching invite -> undefined, no native calls made", async () => {
const store = createInMemoryAccessPolicyStore();
let calls = 0;
const api = fakeApi(() => {
calls += 1;
throw new Error("should not be called");
});
const result = await resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "nobody@acme.example",
...verified,
});
expect(result).toBeUndefined();
expect(calls).toBe(0);
});

test("exploit: an unverified email cannot hijack a pending invite for someone else's address", async () => {
const store = createInMemoryAccessPolicyStore();
await store.createPendingInvite("tnt_acme", {
matchType: "email",
value: "victim@acme.example",
});
let calls = 0;
const api = fakeApi(() => {
calls += 1;
throw new Error("should not be called");
});

const result = await resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "victim@acme.example",
emailVerified: false,
allowUnverifiedEmails: false,
});

expect(result).toBeUndefined();
expect(calls).toBe(0);
// The invite survives untouched — an unverified claim never even
// looks it up, let alone consumes it.
const stillPending = await store.findMatchingPendingInvite(
"victim@acme.example",
);
expect(stillPending).toBeDefined();
});

test("an exact-email match invites, activates, and is consumed", async () => {
const store = createInMemoryAccessPolicyStore();
const invite = await store.createPendingInvite("tnt_acme", {
matchType: "email",
value: "Person@Acme.example",
roleId: "rol_member",
});

const calls: { method: string; path: string; body: unknown }[] = [];
const api = fakeApi((method, path, body) => {
calls.push({ method, path, body });
if (method === "POST" && path.endsWith("/members/invite")) {
return { status: 201, data: { id: "prn_new" }, cookies: [] };
}
if (method === "PATCH" && path.endsWith("/prn_new")) {
return { status: 200, data: { id: "prn_new" }, cookies: [] };
}
throw new Error(`unexpected call ${method} ${path}`);
});

const result = await resolvePendingInviteOnLogin({
store,
api,
cookies: ["session=abc"],
email: "person@acme.example",
...verified,
});

expect(result).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });
expect(calls).toEqual([
{
method: "POST",
path: "/api/tenants/tnt_acme/members/invite",
body: { email: "person@acme.example", roleId: "rol_member" },
},
{
method: "PATCH",
path: "/api/tenants/tnt_acme/principals/prn_new",
body: { status: "active" },
},
]);

const stillMatches = await store.findMatchingPendingInvite(
"person@acme.example",
);
expect(stillMatches).toBeUndefined();
void invite;
});

test("a domain-wildcard match resolves but is never consumed (a standing rule)", async () => {
const store = createInMemoryAccessPolicyStore();
await store.createPendingInvite("tnt_acme", {
matchType: "domain",
value: "acme.example",
});

const api = fakeApi((method, path) => {
if (method === "POST" && path.endsWith("/members/invite")) {
return { status: 201, data: { id: "prn_new" }, cookies: [] };
}
if (method === "PATCH") {
return { status: 200, data: { id: "prn_new" }, cookies: [] };
}
throw new Error(`unexpected call ${method} ${path}`);
});

const first = await resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "anyone@acme.example",
...verified,
});
expect(first).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });

const second = await resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "someone-else@acme.example",
...verified,
});
expect(second).toEqual({ tenantId: "tnt_acme", principalId: "prn_new" });
});

test("a redemption failure at the native invite route throws; the invite is already spent (consume-before-redeem)", async () => {
const store = createInMemoryAccessPolicyStore();
await store.createPendingInvite("tnt_acme", {
matchType: "email",
value: "person@acme.example",
});
const api = fakeApi(() => ({
status: 409,
data: { error: { code: "conflict" } },
cookies: [],
}));

await expect(
resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "person@acme.example",
...verified,
}),
).rejects.toThrow(/could not be redeemed/);

// Consumption happens before the native call specifically so a
// race never lets two callers both redeem — the tradeoff is that a
// downstream failure (network blip, native route down) leaves this
// invite spent with no member added. That is deliberate: fail
// closed on the race, not on the rare native-route failure.
const stillPending = await store.findMatchingPendingInvite(
"person@acme.example",
);
expect(stillPending).toBeUndefined();
});

test("TOCTOU: two concurrent redemptions of the same exact-email invite — exactly one wins", async () => {
const store = createInMemoryAccessPolicyStore();
await store.createPendingInvite("tnt_acme", {
matchType: "email",
value: "person@acme.example",
});

let inviteCalls = 0;
const api = fakeApi((method, path) => {
if (method === "POST" && path.endsWith("/members/invite")) {
inviteCalls += 1;
return { status: 201, data: { id: "prn_new" }, cookies: [] };
}
if (method === "PATCH") {
return { status: 200, data: { id: "prn_new" }, cookies: [] };
}
throw new Error(`unexpected call ${method} ${path}`);
});

const [first, second] = await Promise.all([
resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "person@acme.example",
...verified,
}),
resolvePendingInviteOnLogin({
store,
api,
cookies: [],
email: "person@acme.example",
...verified,
}),
]);

const winners = [first, second].filter((r) => r !== undefined);
const losers = [first, second].filter((r) => r === undefined);
expect(winners).toHaveLength(1);
expect(losers).toHaveLength(1);
// The loser never reaches the native invite route at all — it
// loses at the atomic consume step, before any redemption call.
expect(inviteCalls).toBe(1);
});
});
Loading
Loading