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
262 changes: 257 additions & 5 deletions packages/graphql/src/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const memberId = "00000000-0000-4000-8000-000000000002";
const pendingMemberId = "00000000-0000-4000-8000-000000000003";
const acceptedInstanceId = "00000000-0000-4000-8000-000000000101";
const pendingInstanceId = "00000000-0000-4000-8000-000000000102";
const sessionId = "00000000-0000-4000-8000-000000000201";
const accessToken = "test-access-token";
const otherSessionId = "00000000-0000-4000-8000-000000000202";
const otherAccessToken = "other-access-token";
const siteAdminId = "00000000-0000-4000-8000-000000000005";
const memberNodeId = btoa(`Account:${memberId}`);

const accountByUuidQuery = `
query Account($uuid: UUID!) {
Expand All @@ -41,6 +47,37 @@ const accountByUuidQuery = `
}
`;

const accountNodeQuery = `
query AccountNode($id: ID!) {
node(id: $id) {
__typename
... on Account {
uuid
name
email
admin
instances {
totalCount
}
}
}
}
`;

const accountPrivateFieldsQuery = `
query AccountPrivateFields($uuid: UUID!) {
accountByUuid(uuid: $uuid) {
uuid
name
email
admin
instances {
totalCount
}
}
}
`;

const accountInstancesQuery = `
query AccountInstances($uuid: UUID!) {
accountByUuid(uuid: $uuid) {
Expand Down Expand Up @@ -93,6 +130,180 @@ const accountInstancesResponse = {

describe("accountByUuid", () => {
it("returns an account by UUID", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
const auth = await createSession(db);

const response = await post(
{ query: accountByUuidQuery, variables: { uuid: accountId } },
auth,
);

assert.equal(response.status, ok);
assert.deepEqual(await response.json(), accountByUuidResponse);
});
});
});

describe("Account field authorization", () => {
it("hides another account's private fields from a logged-in viewer", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
// Authenticated as `accountId`, asking about `memberId`.
const auth = await createSession(db);

const response = await post(
{ query: accountPrivateFieldsQuery, variables: { uuid: memberId } },
auth,
);

assert.equal(response.status, ok);
const body = await response.json();
// Public fields survive; each private field is individually null rather
// than nulling the whole `Account`.
assert.deepEqual(body.data.accountByUuid, {
uuid: memberId,
name: "Member",
email: null,
admin: null,
instances: null,
});
assert.deepEqual(
body.errors.map((e: { message: string }) => e.message).sort(),
[
"Not authorized to resolve Account.admin",
"Not authorized to resolve Account.email",
"Not authorized to resolve Account.instances",
],
);
});
});

it("shows an account its own private fields", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
const auth = await createSession(db);

const response = await post(
{ query: accountPrivateFieldsQuery, variables: { uuid: accountId } },
auth,
);

assert.equal(response.status, ok);
assert.deepEqual(await response.json(), {
data: {
accountByUuid: {
uuid: accountId,
name: "Owner",
email: "owner@example.com",
admin: false,
instances: { totalCount: 0 },
},
},
});
});
});

it("shows a site administrator another account's private fields", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
await db.insert(schema.accounts).values({
id: siteAdminId,
email: "admin@example.com",
name: "Site Admin",
admin: true,
created,
});
const auth = await createSession(db, {
id: otherSessionId,
account: siteAdminId,
token: otherAccessToken,
});

const response = await post(
{ query: accountPrivateFieldsQuery, variables: { uuid: memberId } },
auth,
);

assert.equal(response.status, ok);
assert.deepEqual(await response.json(), {
data: {
accountByUuid: {
uuid: memberId,
name: "Member",
email: "member@example.com",
admin: false,
instances: { totalCount: 0 },
},
},
});
});
});

it("hides private fields reached through `node(id:)` from another account", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
const auth = await createSession(db);

const response = await post(
{ query: accountNodeQuery, variables: { id: memberNodeId } },
auth,
);

assert.equal(response.status, ok);
const body = await response.json();
assert.deepEqual(body.data.node, {
__typename: "Account",
uuid: memberId,
name: "Member",
email: null,
admin: null,
instances: null,
});
assert.deepEqual(
body.errors.map((e: { message: string }) => e.message).sort(),
[
"Not authorized to resolve Account.admin",
"Not authorized to resolve Account.email",
"Not authorized to resolve Account.instances",
],
);
});
});

it("hides private fields reached through `node(id:)` from an anonymous viewer", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);

// `node(id:)` carries no `authenticated` scope, unlike `accountByUuid`,
// so this is the entry path that the field scopes alone must hold.
const response = await post({
query: accountNodeQuery,
variables: { id: memberNodeId },
});

assert.equal(response.status, ok);
const body = await response.json();
assert.deepEqual(body.data.node, {
__typename: "Account",
uuid: memberId,
name: "Member",
email: null,
admin: null,
instances: null,
});
assert.deepEqual(
body.errors.map((e: { message: string }) => e.message).sort(),
[
"Not authorized to resolve Account.admin",
"Not authorized to resolve Account.email",
"Not authorized to resolve Account.instances",
],
);
});
});

it("denies `accountByUuid` to an unauthenticated viewer", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);

Expand All @@ -102,7 +313,12 @@ describe("accountByUuid", () => {
});

assert.equal(response.status, ok);
assert.deepEqual(await response.json(), accountByUuidResponse);
const body = await response.json();
assert.equal(body.data.accountByUuid, null);
assert.equal(
body.errors[0].message,
"Not authorized to resolve Query.accountByUuid",
);
});
});
});
Expand All @@ -111,18 +327,54 @@ describe("Account.instances", () => {
it("returns the account's accepted instances", async () => {
await withTestHarness(async ({ db, post }) => {
await seedMembershipGraph(db);
const auth = await createSession(db);

const response = await post({
query: accountInstancesQuery,
variables: { uuid: accountId },
});
const response = await post(
{ query: accountInstancesQuery, variables: { uuid: accountId } },
auth,
);

assert.equal(response.status, ok);
assert.deepEqual(await response.json(), accountInstancesResponse);
});
});
});

/**
* Opens an authenticated session for an already-seeded account, then returns
* the request options carrying the session's bearer token.
*
* @param db The database to seed.
* @param options The session id, account id, and bearer token to use. Each
* defaults to {@link accountId}'s.
* @returns Request options with an `Authorization` header for {@link post}.
*/
async function createSession(
db: Database,
options: { id?: string; account?: string; token?: string } = {},
): Promise<RequestInit> {
const { id = sessionId, account = accountId, token = accessToken } = options;
await db.insert(schema.sessions).values({
id,
accountId: account,
tokenHash: await hashSecret(token),
});
return { headers: { authorization: `Bearer ${token}` } };
}

/**
* Computes the SHA-256 hex digest the server stores for a bearer token,
* mirroring `hashSecret` in *auth/hash.ts*.
*
* @param raw The raw access token.
* @returns The lowercase hex-encoded SHA-256 digest.
*/
async function hashSecret(raw: string): Promise<string> {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)),
).toHex();
}

async function seedAccounts(db: Database): Promise<void> {
await db.insert(schema.accounts).values([
{
Expand Down
40 changes: 37 additions & 3 deletions packages/graphql/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ import builder, { type DrFedObjectRef } from "./builder.ts";
// oxlint-disable-next-line import/no-cycle
import { Instance } from "./instance.ts";

/**
* The auth scopes for `Account` fields that only the `Account` itself and
* site administrators may read.
*
* @param account The `Account` the field belongs to.
* @returns A scope map granting access to the account itself or a site
* administrator.
*/
function selfOrSiteAdmin(account: { readonly id: string }) {
return {
$any: {
admin: true,
accountSelf: account.id,
// Granted by `Session.account`, whose account is always the viewer's own
// but which is resolved on a request that carries no session yet.
$granted: "ownAccount",
},
};
}

const AccountRef = builder.drizzleNode("accounts", {
name: "Account",
description:
Expand All @@ -39,13 +59,21 @@ const AccountRef = builder.drizzleNode("accounts", {
}),
email: t.expose("email", {
type: "Email",
description: "The email address of the `Account`.",
nullable: true,
description:
"The email address of the `Account`. `null` unless the viewer is " +
"the `Account` itself or a site administrator.",
authScopes: selfOrSiteAdmin,
}),
name: t.exposeString("name", {
description: "The display name of the `Account`.",
}),
admin: t.exposeBoolean("admin", {
description: "Whether the `Account` has administrator privileges.",
nullable: true,
description:
"Whether the `Account` has administrator privileges. `null` unless " +
"the viewer is the `Account` itself or a site administrator.",
authScopes: selfOrSiteAdmin,
}),
created: t.expose("created", {
type: "DateTime",
Expand Down Expand Up @@ -83,7 +111,12 @@ builder.drizzleObjectField(AccountRef, "instances", (t) =>
t.connection(
{
type: Instance,
description: "The `Instance`s that the `Account` belongs to.",
nullable: true,
description:
"The `Instance`s that the `Account` belongs to. `null` unless the " +
"viewer is the `Account` itself or a site administrator; an empty " +
"connection means the `Account` belongs to no `Instance`.",
authScopes: selfOrSiteAdmin,
select(args, ctx, nestedSelection) {
return {
with: {
Expand Down Expand Up @@ -164,6 +197,7 @@ builder.queryFields((t) => ({
},
description: "Get an `Account` by its UUID.",
nullable: true,
authScopes: { authenticated: true },
resolve(query, _, { uuid }, ctx) {
return ctx.db.query.accounts.findFirst(query({ where: { id: uuid } }));
},
Expand Down
Loading