140 lines
6.2 KiB
TypeScript
140 lines
6.2 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// Who may open which live stream (@conusai-kit/host/postgres `createHostLive`, umbrella TS6.2), decided with the
|
|
// kernel's own tenancy over this app's registry: a member gets the collection for a topic they
|
|
// resolve, with its ids; a signed-out request is 401; another organization's slug and a workspace
|
|
// the caller does not resolve are 404; an entity that is not a collection is denied, and so is a
|
|
// project collection asked for the whole organization; a member removed meanwhile is revoked at the
|
|
// next re-check, and one whose role lost the read permission of a granted collection is told
|
|
// `grants_changed`. The stream itself is proven by tests/live-conformance and e2e/notes-live.spec.ts.
|
|
|
|
import type { Principal } from "@conusai-kit/auth"
|
|
import { createHostLive, createLiveGrants } from "@conusai-kit/host/postgres"
|
|
import { createTenancyHooks, orgMemberships, TENANCY } from "@conusai-kit/tenancy"
|
|
import { describe, expect, it } from "vitest"
|
|
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
|
|
|
|
const app = makeApp()
|
|
let principal: Principal | null = null
|
|
const quiet = {
|
|
log: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
captureException: () => undefined,
|
|
span: <T>(_name: string, _attributes: unknown, fn: () => T): T => fn(),
|
|
}
|
|
|
|
const { authorize } = createHostLive({
|
|
runtime: () => ({ registry: app.registry, observe: quiet }),
|
|
requestContext: async () => {
|
|
const container = app.root.child()
|
|
container.value(TENANCY, createTenancyHooks({ data: app.data, registry: app.registry }))
|
|
return { principal, container }
|
|
},
|
|
log: () => undefined,
|
|
grants: createLiveGrants(),
|
|
})
|
|
|
|
const as = (person: Person | null) => {
|
|
principal = person ? ({ actor: person.actor, method: "bearer" } as Principal) : null
|
|
}
|
|
const open = (org: string, body: { entities: string[]; topics: Array<{ ws: string; project?: string }> }) =>
|
|
authorize(new Request(`http://app.test/api/v1/orgs/${org}/live`, { method: "POST" }), {
|
|
protocol: "clp/1",
|
|
after: null,
|
|
...body,
|
|
})
|
|
|
|
async function orgWithProject(person: Person, slug: string) {
|
|
const call = person.call()
|
|
const created = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
|
|
await call.callByName("tenancy.workspace.create", { org: slug, slug: "main", name: "Main", visibility: "org" })
|
|
await call.callByName("tenancy.project.create", {
|
|
org: slug,
|
|
workspace: "main",
|
|
slug: "notes",
|
|
name: "Notes",
|
|
moduleKey: "notes",
|
|
})
|
|
return created.orgId
|
|
}
|
|
|
|
describe("the live stream's authorization", async () => {
|
|
const ada = await app.signUp("ada-live@example.test")
|
|
const grace = await app.signUp("grace-live@example.test")
|
|
const acme = await orgWithProject(ada, "acme-live")
|
|
await orgWithProject(grace, "globex-live")
|
|
const topic = { ws: "main", project: "notes" }
|
|
|
|
it("grants a member the collection for a topic they resolve, with its ids, and denies the rest", async () => {
|
|
as(ada)
|
|
const grant = await open("acme-live", { entities: ["notes.note", "notes.secret"], topics: [topic] })
|
|
expect(grant.org).toBe(acme)
|
|
expect(grant.granted.entities).toEqual(["notes.note"])
|
|
expect(grant.granted.topics[0]?.ids.org).toBe(acme)
|
|
expect(grant.granted.topics[0]?.ids.ws).toBeTruthy()
|
|
expect(grant.granted.topics[0]?.ids.project).toBeTruthy()
|
|
expect(grant.denied).toEqual([{ entity: "notes.secret", reason: "unknown" }])
|
|
expect(await grant.recheck()).toBeNull()
|
|
})
|
|
|
|
it("denies a project collection for the whole organization", async () => {
|
|
as(ada)
|
|
const grant = await open("acme-live", { entities: ["notes.note"], topics: [] })
|
|
expect(grant.granted.entities).toEqual([])
|
|
expect(grant.denied).toEqual([{ entity: "notes.note", reason: "scope" }])
|
|
})
|
|
|
|
it("refuses a signed-out request, another organization's slug and a workspace the caller does not resolve", async () => {
|
|
as(null)
|
|
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
|
status: 401,
|
|
})
|
|
as(grace)
|
|
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
|
status: 404,
|
|
})
|
|
as(ada)
|
|
await expect(open("acme-live", { entities: ["notes.note"], topics: [{ ws: "nowhere" }] })).rejects.toMatchObject({
|
|
status: 404,
|
|
})
|
|
await expect(open("globex-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
|
|
status: 404,
|
|
})
|
|
})
|
|
|
|
it("ends a stream with grants_changed once the member's role no longer reads a granted collection", async () => {
|
|
const linus = await app.signUp("linus-live@example.test")
|
|
const invitation = (await ada.call().callByName("tenancy.member.invite", {
|
|
org: "acme-live",
|
|
email: linus.email,
|
|
role: "member",
|
|
})) as { token: string }
|
|
await linus.call().callByName("tenancy.invitation.accept", { org: "acme-live", token: invitation.token })
|
|
as(linus)
|
|
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
|
|
expect(grant.granted.entities).toEqual(["notes.note"])
|
|
expect(await grant.recheck()).toBeNull()
|
|
// A custom organization role that reads the organization and nothing of the notes module.
|
|
const role = (await ada.call().callByName("tenancy.role.create", {
|
|
org: "acme-live",
|
|
name: "Directory only",
|
|
level: "org",
|
|
permissions: ["tenancy.org.read", "tenancy.workspace.read", "tenancy.project.read"],
|
|
})) as { key: string }
|
|
await ada.call().callByName("tenancy.member.assign", {
|
|
org: "acme-live",
|
|
userId: linus.actor.userId,
|
|
level: "org",
|
|
roleKey: role.key,
|
|
})
|
|
expect(await grant.recheck()).toBe("grants_changed")
|
|
})
|
|
|
|
it("revokes a member who was removed, at the next re-check", async () => {
|
|
as(ada)
|
|
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
|
|
const scope = { level: "org" as const, orgId: acme, actor: ada.actor }
|
|
const rows = (await app.data.forScope(scope).of(orgMemberships).list({})).items
|
|
for (const row of rows) await app.data.forScope(scope).of(orgMemberships).delete(row.id)
|
|
expect(await grant.recheck()).toBe("membership")
|
|
})
|
|
})
|