feat(authz): add organization member authorization check
This commit is contained in:
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
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(),
|
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).
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user