255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The web process's tenancy rules around a request's caller (apps/web/src/server/tenancy-calls.ts,
|
|
// ADR-062), over the app's real registry and kit-tenancy's procedures:
|
|
//
|
|
// - an invitation, once committed, admits its address through @conusai-kit/auth's real platform client
|
|
// against the fake admission endpoint of @conusai-kit/testing, audited with ids only
|
|
// - with no admission credential the invitation still answers, and the skip is logged with its reason
|
|
// - a refused or unreachable admission is logged and audited as a failure, never thrown to the inviter
|
|
// - an invitation that is refused (a role above the inviter's) asks for no admission at all
|
|
// - with self-service off, only the app-access role `admin` founds an organization; a token without the
|
|
// claim is not `admin`
|
|
// - on the Convex profile a grant-changing write is announced to the organization's streams here
|
|
|
|
import { createPlatformIdentityAdmission } from "@conusai-kit/auth"
|
|
import { Container } from "@conusai-kit/core"
|
|
import { GRANTS_CHANGED } from "@conusai-kit/tenancy"
|
|
import { fakeIdentityAdmission } from "@conusai-kit/testing"
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import { type TenancyCallOptions, withTenancyCalls } from "../apps/web/src/server/tenancy-calls.js"
|
|
import { identityLinkOf, organizationSelfServiceOf } from "../config/src/access.js"
|
|
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
|
|
|
|
const app = makeApp()
|
|
|
|
type Audited = Parameters<TenancyCallOptions["audit"]>[1]
|
|
|
|
function wrapped(person: Person, overrides: Partial<TenancyCallOptions> = {}) {
|
|
const audited: Audited[] = []
|
|
const log = { info: vi.fn(), warn: vi.fn() }
|
|
const caller = withTenancyCalls(person.call(), {
|
|
principal: { actor: person.actor },
|
|
container: new Container(),
|
|
selfServiceOrganizations: true,
|
|
admission: () => ({ skipped: "no OIDC_ADMISSION_TOKEN" }),
|
|
audit: async (_action, event) => {
|
|
audited.push(event)
|
|
},
|
|
log,
|
|
...overrides,
|
|
})
|
|
return { caller, audited, log }
|
|
}
|
|
|
|
describe("an invitation's admission (ADR-062 decision 4)", async () => {
|
|
const ada = await app.signUp("ada-admission@example.test")
|
|
await ada.call().callByName("tenancy.org.create", { slug: "acme-admission", name: "Acme" })
|
|
|
|
it("admits the invited address once the invitation committed, and audits ids only", async () => {
|
|
const platform = fakeIdentityAdmission()
|
|
const admission = createPlatformIdentityAdmission({
|
|
apiUrl: platform.apiUrl,
|
|
linkId: platform.linkId,
|
|
token: platform.token,
|
|
fetch: platform.fetch,
|
|
})
|
|
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
|
|
const invitation = (await caller.callByName("tenancy.member.invite", {
|
|
org: "acme-admission",
|
|
email: " New.Person@Example.test ",
|
|
role: "member",
|
|
})) as { invitationId: string; token: string }
|
|
expect(invitation.token.length).toBeGreaterThan(16)
|
|
expect(platform.admissions).toEqual([
|
|
expect.objectContaining({ email: "new.person@example.test", invitationId: invitation.invitationId }),
|
|
])
|
|
expect(platform.calls[0]).toMatchObject({ method: "POST", credential: "valid", status: 202 })
|
|
expect(audited).toEqual([{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "success" }])
|
|
// Neither the log nor the audit event carries the address.
|
|
expect(JSON.stringify([log.info.mock.calls, log.warn.mock.calls, audited])).not.toContain("example.test")
|
|
})
|
|
|
|
it("answers the invitation without an admission credential, and logs why it skipped", async () => {
|
|
const { caller, audited, log } = wrapped(ada)
|
|
const invitation = (await caller.callByName("tenancy.member.invite", {
|
|
org: "acme-admission",
|
|
email: "skipped@example.test",
|
|
role: "viewer",
|
|
})) as { invitationId: string }
|
|
expect(invitation.invitationId).toBeTruthy()
|
|
expect(audited).toEqual([])
|
|
expect(log.info).toHaveBeenCalledWith("invitation: admission skipped: no OIDC_ADMISSION_TOKEN", {
|
|
"conusai.invitation_id": invitation.invitationId,
|
|
})
|
|
})
|
|
|
|
it("answers the invitation when the admission client cannot be built", async () => {
|
|
const { caller, log } = wrapped(ada, {
|
|
admission: () => {
|
|
throw new Error("set OIDC_AUDIENCE")
|
|
},
|
|
})
|
|
const invitation = (await caller.callByName("tenancy.member.invite", {
|
|
org: "acme-admission",
|
|
email: "unbuilt@example.test",
|
|
role: "viewer",
|
|
})) as { invitationId: string }
|
|
expect(log.info).toHaveBeenCalledWith(
|
|
"invitation: admission skipped: the admission client could not be built (Error)",
|
|
{
|
|
"conusai.invitation_id": invitation.invitationId,
|
|
},
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
[429, "rate_limited"],
|
|
[401, "unauthorized"],
|
|
[503, "unavailable"],
|
|
["network", "unavailable"],
|
|
] as const)(
|
|
"keeps the invitation when the platform answers %s, logged and audited as %s",
|
|
async (refusal, reason) => {
|
|
const platform = fakeIdentityAdmission()
|
|
platform.refuseNext(refusal)
|
|
const admission = createPlatformIdentityAdmission({
|
|
apiUrl: platform.apiUrl,
|
|
linkId: platform.linkId,
|
|
token: platform.token,
|
|
fetch: platform.fetch,
|
|
})
|
|
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
|
|
const email = `refused-${String(refusal)}@example.test`
|
|
const invitation = (await caller.callByName("tenancy.member.invite", {
|
|
org: "acme-admission",
|
|
email,
|
|
role: "viewer",
|
|
})) as { invitationId: string }
|
|
expect(invitation.invitationId).toBeTruthy()
|
|
expect(platform.admissions).toEqual([])
|
|
expect(log.warn).toHaveBeenCalledWith(`invitation: admission failed: ${reason}`, {
|
|
"conusai.invitation_id": invitation.invitationId,
|
|
})
|
|
expect(audited).toEqual([
|
|
{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "failure", reason },
|
|
])
|
|
},
|
|
)
|
|
|
|
it("asks for no admission when the invitation itself is refused", async () => {
|
|
const grace = await app.signUp("grace-admission@example.test")
|
|
const joined = (await ada.call().callByName("tenancy.member.invite", {
|
|
org: "acme-admission",
|
|
email: grace.email,
|
|
role: "admin",
|
|
})) as { token: string }
|
|
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-admission", token: joined.token })
|
|
const platform = fakeIdentityAdmission()
|
|
const admission = createPlatformIdentityAdmission({
|
|
apiUrl: platform.apiUrl,
|
|
linkId: platform.linkId,
|
|
token: platform.token,
|
|
fetch: platform.fetch,
|
|
})
|
|
const { caller, audited } = wrapped(grace, { admission: () => admission })
|
|
// An administrator cannot invite an owner (kit-tenancy's ceiling): nothing committed, nothing admitted.
|
|
await expect(
|
|
caller.callByName("tenancy.member.invite", { org: "acme-admission", email: "owner@example.test", role: "owner" }),
|
|
).rejects.toMatchObject({ status: 403 })
|
|
expect(platform.calls).toEqual([])
|
|
expect(audited).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("founding an organization when self-service is off (ADR-062 decision 5)", async () => {
|
|
const linus = await app.signUp("linus-founder@example.test")
|
|
|
|
it("refuses a person without the app-access role, and one whose token carries no claim", async () => {
|
|
for (const principal of [{ actor: linus.actor }, { actor: linus.actor, appAccess: { role: "user" as const } }]) {
|
|
const { caller } = wrapped(linus, { selfServiceOrganizations: false, principal })
|
|
await expect(
|
|
caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Refused" }),
|
|
).rejects.toMatchObject({ status: 403, code: "forbidden" })
|
|
}
|
|
// Nothing was written: the slug is still free for the operator below.
|
|
expect(
|
|
((await linus.call().callByName("tenancy.me.get", undefined)) as { organizations: unknown[] }).organizations,
|
|
).toEqual([])
|
|
})
|
|
|
|
it("lets the app-access role admin found one, and anyone when self-service is on", async () => {
|
|
const admin = wrapped(linus, {
|
|
selfServiceOrganizations: false,
|
|
principal: { actor: linus.actor, appAccess: { role: "admin" } },
|
|
})
|
|
await expect(
|
|
admin.caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Ops" }),
|
|
).resolves.toMatchObject({
|
|
slug: "refused-founder",
|
|
})
|
|
const open = wrapped(linus, { selfServiceOrganizations: true })
|
|
await expect(
|
|
open.caller.callByName("tenancy.org.create", { slug: "self-founded", name: "Self" }),
|
|
).resolves.toMatchObject({
|
|
slug: "self-founded",
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("a grant change made in the Convex deployment", async () => {
|
|
const ada = await app.signUp("ada-remote-grants@example.test")
|
|
await ada.call().callByName("tenancy.org.create", { slug: "acme-remote", name: "Acme" })
|
|
const grace = await app.signUp("grace-remote-grants@example.test")
|
|
const invitation = (await ada.call().callByName("tenancy.member.invite", {
|
|
org: "acme-remote",
|
|
email: grace.email,
|
|
role: "member",
|
|
})) as { token: string }
|
|
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-remote", token: invitation.token })
|
|
|
|
it("is announced to this process's streams of the organization, and only of a streamed one", async () => {
|
|
const container = new Container()
|
|
const announced: string[] = []
|
|
container.value(GRANTS_CHANGED, { grantsChanged: async (orgId) => void announced.push(orgId) })
|
|
const orgIdOf = (slug: string) => (slug === "acme-remote" ? "org-id-of-acme" : undefined)
|
|
const { caller } = wrapped(ada, { container, remoteGrants: { orgIdOf } })
|
|
await caller.callByName("tenancy.member.change-role", {
|
|
org: "acme-remote",
|
|
userId: grace.actor.userId,
|
|
role: "viewer",
|
|
})
|
|
expect(announced).toEqual(["org-id-of-acme"])
|
|
// A read is no grant change.
|
|
await caller.callByName("tenancy.member.list", { org: "acme-remote" })
|
|
expect(announced).toEqual(["org-id-of-acme"])
|
|
})
|
|
})
|
|
|
|
describe("the access settings of the deployment", () => {
|
|
it("reads APP_ORGANIZATION_SELF_SERVICE as true by default and refuses anything but true or false", () => {
|
|
expect(organizationSelfServiceOf({})).toBe(true)
|
|
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "" })).toBe(true)
|
|
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "false" })).toBe(false)
|
|
expect(() => organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "no" })).toThrow(
|
|
"set APP_ORGANIZATION_SELF_SERVICE to true or false",
|
|
)
|
|
})
|
|
|
|
it("takes the identity link with its admission credential, skips without both, and refuses one of the two", () => {
|
|
expect(identityLinkOf({})).toEqual({
|
|
skipped: "no OIDC_ADMISSION_TOKEN: the identity link issued no admission credential to this app",
|
|
})
|
|
expect(identityLinkOf({ OIDC_LINK_ID: "4f1c2a7e-6b1d", OIDC_ADMISSION_TOKEN: "cai_adm_x" })).toEqual({
|
|
linkId: "4f1c2a7e-6b1d",
|
|
})
|
|
for (const env of [{ OIDC_LINK_ID: "4f1c" }, { OIDC_ADMISSION_TOKEN: "cai_adm_secret" }]) {
|
|
expect(() => identityLinkOf(env)).toThrow("set OIDC_LINK_ID and OIDC_ADMISSION_TOKEN together")
|
|
}
|
|
// The credential is never quoted back.
|
|
expect(() => identityLinkOf({ OIDC_ADMISSION_TOKEN: "cai_adm_secret" })).not.toThrow(/cai_adm_secret/)
|
|
expect(() => identityLinkOf({ OIDC_LINK_ID: "../other", OIDC_ADMISSION_TOKEN: "t" })).toThrow(
|
|
"set OIDC_LINK_ID to the identity link's id",
|
|
)
|
|
})
|
|
})
|