feat(authz): add organization member authorization check

This commit is contained in:
-Puter
2026-07-23 15:42:30 +05:30
parent ca967fcc1c
commit c6f3cd330a
4 changed files with 252 additions and 0 deletions

View File

@@ -1,5 +1,8 @@
import { ConvexError } from "convex/values"; import { ConvexError } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
export interface AuthContext { export interface AuthContext {
readonly auth: { readonly auth: {
readonly getUserIdentity: () => Promise<{ readonly getUserIdentity: () => Promise<{
@@ -15,3 +18,25 @@ export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
} }
return identity.tokenIdentifier; 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;
};

View 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);
});
});

View 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;
},
});

View File

@@ -147,6 +147,26 @@ export default defineSchema({
completed: v.boolean(), 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). // Flue persistence stores (schema/format version 4).
// ----------------------------------------------------------------- // -----------------------------------------------------------------