feat(authz): add organization member authorization check
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
export interface AuthContext {
|
||||
readonly auth: {
|
||||
readonly getUserIdentity: () => Promise<{
|
||||
@@ -15,3 +18,25 @@ export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
}
|
||||
return identity.tokenIdentifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the authenticated identity and prove it is a member of the given
|
||||
* organization. Throws on unauthenticated or non-member access. Returns the
|
||||
* canonical user ID (the Better Auth `tokenIdentifier`).
|
||||
*/
|
||||
export const requireOrganizationMember = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
organizationId: Id<"organizations">
|
||||
): Promise<string> => {
|
||||
const userId = await requireOwnerId(ctx);
|
||||
const membership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
q.eq("organizationId", organizationId).eq("userId", userId)
|
||||
)
|
||||
.unique();
|
||||
if (!membership) {
|
||||
throw new ConvexError("Organization membership required");
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
125
packages/backend/convex/organizations.test.ts
Normal file
125
packages/backend/convex/organizations.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
import schema from "./schema";
|
||||
|
||||
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
||||
// has no type definition under the Convex tsconfig (which scopes `types` to
|
||||
// node), so declare the minimal shape the test relies on.
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
describe("organizations", () => {
|
||||
test("first ensure creates one organization and owner membership", async () => {
|
||||
const t = newTest();
|
||||
const org = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
|
||||
expect(org.kind).toBe("personal");
|
||||
expect(org.name).toBe("Personal");
|
||||
expect(org.createdBy).toBe(ID_A);
|
||||
expect(org._id).toBeTruthy();
|
||||
|
||||
// Idempotent shape: a second ensure returns the same organization.
|
||||
const again = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
expect(again._id).toBe(org._id);
|
||||
|
||||
// The current-organization query reflects the ensured org.
|
||||
const current = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.organizations.getCurrent, {});
|
||||
expect(current?._id).toBe(org._id);
|
||||
});
|
||||
|
||||
test("repeated ensure returns the same organization without duplicates", async () => {
|
||||
const t = newTest();
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
|
||||
// Many subsequent ensures must all resolve to the single org.
|
||||
const repeats = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {})
|
||||
)
|
||||
);
|
||||
|
||||
for (const org of repeats) {
|
||||
expect(org._id).toBe(first._id);
|
||||
}
|
||||
|
||||
// The current-organization query still reports the single org.
|
||||
const allForA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.organizations.getCurrent, {});
|
||||
expect(allForA?._id).toBe(first._id);
|
||||
});
|
||||
|
||||
test("unauthenticated access is denied", async () => {
|
||||
const t = newTest();
|
||||
|
||||
await expect(t.query(api.organizations.getCurrent, {})).rejects.toThrow(
|
||||
/Authentication required/u
|
||||
);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.organizations.ensurePersonalOrganization, {})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
});
|
||||
|
||||
test("a second identity cannot read or mutate the first organization", async () => {
|
||||
const t = newTest();
|
||||
|
||||
const orgA = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
|
||||
// The second identity sees none of the first identity's organizations.
|
||||
const currentForB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.organizations.getCurrent, {});
|
||||
expect(currentForB).toBeNull();
|
||||
|
||||
// The second identity gets its own distinct organization.
|
||||
const orgB = await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
expect(orgB._id).not.toBe(orgA._id);
|
||||
|
||||
// The membership boundary denies the second identity access to org A.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// But the first identity is a confirmed member of its own organization.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityA)
|
||||
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
|
||||
).resolves.toBe(ID_A);
|
||||
});
|
||||
});
|
||||
82
packages/backend/convex/organizations.ts
Normal file
82
packages/backend/convex/organizations.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: Id<"organizations">;
|
||||
readonly _creationTime: number;
|
||||
readonly name: string;
|
||||
readonly kind: "personal" | "team";
|
||||
readonly createdBy: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
const toView = (org: Doc<"organizations">): OrganizationView => ({
|
||||
_creationTime: org._creationTime,
|
||||
_id: org._id,
|
||||
createdAt: org.createdAt,
|
||||
createdBy: org.createdBy,
|
||||
kind: org.kind,
|
||||
name: org.name,
|
||||
});
|
||||
|
||||
/**
|
||||
* Idempotently create (or return) the authenticated user's single personal
|
||||
* organization plus an owner membership. Convex mutations are atomic
|
||||
* transactions: if two concurrent calls both observe no existing org and both
|
||||
* insert, the transaction's optimistic-concurrency check rejects one; the
|
||||
* retried call re-reads and returns the now-existing org. The read+write is the
|
||||
* whole transaction, so no duplicate personal org can persist.
|
||||
*/
|
||||
export const ensurePersonalOrganization = mutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", ownerId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return toView(existing);
|
||||
}
|
||||
const createdAt = Date.now();
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt,
|
||||
createdBy: ownerId,
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
await ctx.db.insert("organizationMembers", {
|
||||
createdAt,
|
||||
organizationId,
|
||||
role: "owner",
|
||||
userId: ownerId,
|
||||
});
|
||||
const created = await ctx.db.get(organizationId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to create personal organization");
|
||||
}
|
||||
return toView(created);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the authenticated user's current organization. In the single personal
|
||||
* organization phase this is the user's one personal org. Returns null when the
|
||||
* user has not yet ensured one. Denies unauthenticated access.
|
||||
*/
|
||||
export const getCurrent = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView | null> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", ownerId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
return existing ? toView(existing) : null;
|
||||
},
|
||||
});
|
||||
@@ -147,6 +147,26 @@ export default defineSchema({
|
||||
completed: v.boolean(),
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Organization tenancy. Organizations are hard tenant boundaries; deny by
|
||||
// default. The membership user ID is the Better Auth Convex identity
|
||||
// `tokenIdentifier`.
|
||||
// -----------------------------------------------------------------
|
||||
organizations: defineTable({
|
||||
name: v.string(),
|
||||
kind: v.union(v.literal("personal"), v.literal("team")),
|
||||
createdBy: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_createdBy_and_kind", ["createdBy", "kind"]),
|
||||
organizationMembers: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
userId: v.string(),
|
||||
role: v.union(v.literal("owner"), v.literal("member")),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Flue persistence stores (schema/format version 4).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user