Skip to content
Open
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
28 changes: 28 additions & 0 deletions apps/web/src/lib/auth/__tests__/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
evaluateAdminRouteGuard,
evaluateApiKeyAuth,
evaluateAuthedRouteGuard,
evaluateBrandAccess,
evaluateBrandRouteGuard,
evaluateDeploymentPolicy,
evaluateOrgScope,
Expand Down Expand Up @@ -452,6 +453,33 @@ describe("evaluateOrgScope", () => {
});
});

// Umbrella-org model (issue #432): a brand's owning org is resolved via
// `brands.organizationId`, not assumed equal to the brand id.
describe("evaluateBrandAccess", () => {
const ORG_A = "org-a";
const ORG_B = "org-b";

it("allows a member of the brand's owning org", () => {
expect(evaluateBrandAccess([ORG_A], ORG_A)).toBe("allow");
});

it("denies a member of a different org", () => {
expect(evaluateBrandAccess([ORG_A], ORG_B)).toBe("deny");
});

it("allows a multi-org member whose set includes the brand's org", () => {
expect(evaluateBrandAccess([ORG_A, ORG_B], ORG_B)).toBe("allow");
});

it("denies when the brand does not exist (brandOrgId is null)", () => {
expect(evaluateBrandAccess([ORG_A], null)).toBe("deny");
});

it("denies when the user has no memberships", () => {
expect(evaluateBrandAccess([], ORG_A)).toBe("deny");
});
});

describe("evaluateReadOnly", () => {
it("denies writes when read-only is enabled", () => {
expect(evaluateReadOnly(true)).toBe("deny");
Expand Down
39 changes: 38 additions & 1 deletion apps/web/src/lib/auth/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { getRequestHeaders } from "@tanstack/react-start/server";
import { db } from "@workspace/lib/db/db";
import { member, organization } from "@workspace/lib/db/schema";
import { member, organization, brands } from "@workspace/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { getDeployment } from "@/lib/config/server";
import { auth } from "./server";
Expand Down Expand Up @@ -47,6 +47,43 @@ export async function requireOrgAccess(userId: string, orgId: string): Promise<v
}
}

/**
* Whether the user may access a brand, resolved through the brand's owning org
* (`brands.organizationId`) — the umbrella-org access rule. A single joined
* query: brand → its org → a membership row for this user.
*/
export async function checkBrandAccess(userId: string, brandId: string): Promise<boolean> {
const [row] = await db
.select({ id: member.id })
.from(brands)
.innerJoin(
member,
and(eq(member.organizationId, brands.organizationId), eq(member.userId, userId)),
)
.where(eq(brands.id, brandId))
.limit(1);
return !!row;
}

export async function requireBrandAccess(userId: string, brandId: string): Promise<void> {
if (!(await checkBrandAccess(userId, brandId))) {
throw new Error("Forbidden: No access to this brand");
}
}

/**
* Resolve a brand's owning org id — the umbrella org whose membership gates
* team management for that brand. Null when the brand doesn't exist.
*/
export async function getBrandOrganizationId(brandId: string): Promise<string | null> {
const [row] = await db
.select({ organizationId: brands.organizationId })
.from(brands)
.where(eq(brands.id, brandId))
.limit(1);
return row?.organizationId ?? null;
}

export async function listUserOrganizations(userId: string): Promise<{ id: string; name: string }[]> {
return db
.select({ id: organization.id, name: organization.name })
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/auth/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ export function evaluateOrgScope(
return memberOrgIds.includes(resourceOrgId) ? "allow" : "deny";
}

/**
* Brand-scoped access rule for the umbrella-org model: a user may act on a
* brand only if they are a member of the brand's owning org. `brandOrgId` is
* null when the brand does not exist — which must deny, never fall through to
* a brand-id-as-org-id match.
*/
export function evaluateBrandAccess(
memberOrgIds: readonly string[],
brandOrgId: string | null,
): "allow" | "deny" {
if (brandOrgId === null) return "deny";
return memberOrgIds.includes(brandOrgId) ? "allow" : "deny";
}

/**
* Evaluate read-only mode enforcement.
* Used by `readOnlyMiddleware` for server functions.
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { getCloudAuthOptions } from "@workspace/cloud/auth-hooks";
import { createAuth, type CreateAuthOptions } from "@workspace/lib/auth/server";
import { getWhitelabelAuthOptions } from "@workspace/whitelabel/auth-hooks";
import { countUsers, provisionLocalOrg } from "@workspace/lib/db/provisioning";
import { countUsers, provisionLocalOrg, provisionUmbrellaOrg } from "@workspace/lib/db/provisioning";
import { evaluateSignupAllowed, getSignupAllowlist } from "./policies";

/**
Expand Down Expand Up @@ -60,8 +60,9 @@ function getDeploymentAuthOptions(): CreateAuthOptions | undefined {
// unlike the UI's canRegister flag. Set CLOUD_SIGNUP_ALLOWLIST to admit
// people ("@elmohq.com,alice@x.com"); empty denies everyone (fails
// closed); "*" opens it up at launch. Sign-in is unaffected — create
// hooks don't fire for existing users. Each user provisions their own
// org via the create-brand flow (canCreateBrands), so no after hook.
// hooks don't fire for existing users. The after hook provisions the
// user's umbrella org (mirroring local's user.create.after below);
// brands attach to it via the create-brand flow (canCreateBrands).
const cloudOptions = getCloudAuthOptions();
const rejectDisposableEmail = cloudOptions.databaseHooks?.user?.create?.before;
return {
Expand All @@ -78,6 +79,12 @@ function getDeploymentAuthOptions(): CreateAuthOptions | undefined {
}
await rejectDisposableEmail?.(user, context);
},
after: async (user) => {
await provisionUmbrellaOrg({
userId: user.id,
name: user.name?.trim() ? `${user.name.trim()}'s workspace` : "My workspace",
});
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ function AcceptInvitationPage() {
setAcceptError(null);
setAccepting(true);
try {
const { orgId } = await acceptInvitationFn({ data: { invitationId } });
navigate({ to: "/app/$brand", params: { brand: orgId } });
await acceptInvitationFn({ data: { invitationId } });
navigate({ to: "/app" });
} catch (err) {
setAcceptError(err instanceof Error ? err.message : "Failed to accept the invitation");
setAccepting(false);
Expand Down
65 changes: 34 additions & 31 deletions apps/web/src/routes/_authed/app/$brand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,46 +32,49 @@ const getBrandData = createServerFn({ method: "GET" })
}> => {
const session = await requireAuthSession();

// Verify access
const brand = await db.query.brands.findFirst({ where: eq(brands.id, data.brandId) });

if (brand) {
const hasAccess = await checkOrgAccess(session.user.id, brand.organizationId);
if (!hasAccess) {
return { brand: null, brandName: null, isAdmin: false, hasReportAccess: false, hasAccess: false };
}

const brandPrompts = await db.query.prompts.findMany({
where: eq(prompts.brandId, data.brandId),
});
const brandCompetitors = await db.query.competitors.findMany({
where: eq(competitors.brandId, data.brandId),
});

return {
brand: {
...brand,
prompts: brandPrompts,
competitors: brandCompetitors,
},
brandName: brand.name,
isAdmin: isAdmin(session),
hasReportAccess: hasReportAccess(session),
hasAccess: true,
};
}

// No brand row: legacy onboarding path where the URL param is an org id
// (brand.id === org.id). Whitelabel empty-org onboarding depends on this.
const hasAccess = await checkOrgAccess(session.user.id, data.brandId);
if (!hasAccess) {
return { brand: null, brandName: null, isAdmin: false, hasReportAccess: false, hasAccess: false };
}

// Get brand metadata (name from org membership — org exists even if not in DB yet)
const orgs = await listUserOrganizations(session.user.id);
const orgMeta = orgs.find((o) => o.id === data.brandId);
const brandName = orgMeta?.name || data.brandId;

const admin = isAdmin(session);
const reportAccess = hasReportAccess(session);

// Get brand data from DB
const brand = await db.query.brands.findFirst({
where: eq(brands.id, data.brandId),
});

if (!brand) {
return { brand: null, brandName, isAdmin: admin, hasReportAccess: reportAccess, hasAccess: true };
}

const brandPrompts = await db.query.prompts.findMany({
where: eq(prompts.brandId, data.brandId),
});

const brandCompetitors = await db.query.competitors.findMany({
where: eq(competitors.brandId, data.brandId),
});

return {
brand: {
...brand,
prompts: brandPrompts,
competitors: brandCompetitors,
},
brandName: brand.name,
isAdmin: admin,
hasReportAccess: reportAccess,
brand: null,
brandName: orgMeta?.name || data.brandId,
isAdmin: isAdmin(session),
hasReportAccess: hasReportAccess(session),
hasAccess: true,
};
});
Expand Down
48 changes: 45 additions & 3 deletions apps/web/src/routes/_authed/app/$brand/settings/members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ import { useState } from "react";
import { getDeployment } from "@/lib/config/server";
import { trackEvent } from "@/lib/posthog";
import { getAppName, getBrandName, buildTitle } from "@/lib/route-head";
import { cancelInvitationFn, inviteTeamMemberFn, listTeamFn, removeTeamMemberFn, type TeamData } from "@/server/team";
import {
cancelInvitationFn,
inviteTeamMemberFn,
listTeamFn,
removeTeamMemberFn,
updateOrganizationFn,
type TeamData,
} from "@/server/team";

const getTeamInvitesEnabled = createServerFn({ method: "GET" }).handler(async () => {
return { teamInvites: getDeployment().features.teamInvites };
Expand Down Expand Up @@ -46,12 +53,28 @@ export const Route = createFileRoute("/_authed/app/$brand/settings/members")({

function TeamSettingsPage() {
const { brand: brandId } = Route.useParams();
const { members, invitations, currentUserId } = Route.useLoaderData();
const { members, invitations, currentUserId, organization } = Route.useLoaderData();
const router = useRouter();
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<"member" | "admin">("member");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaceName, setWorkspaceName] = useState(organization.name);
const [savingWorkspace, setSavingWorkspace] = useState(false);

async function handleSaveWorkspace(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSavingWorkspace(true);
try {
await updateOrganizationFn({ data: { brandId, name: workspaceName } });
await router.invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update workspace name");
} finally {
setSavingWorkspace(false);
}
}

async function handleInvite(e: React.FormEvent) {
e.preventDefault();
Expand Down Expand Up @@ -94,7 +117,7 @@ function TeamSettingsPage() {
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Team</h1>
<p className="text-muted-foreground">Invite teammates and manage who has access to this brand.</p>
<p className="text-muted-foreground">Invite teammates and manage who has access to your workspace.</p>
</div>

{error && (
Expand All @@ -103,6 +126,25 @@ function TeamSettingsPage() {
</Alert>
)}

<div className="space-y-3">
<h2 className="text-lg font-semibold">Workspace</h2>
<form onSubmit={handleSaveWorkspace} className="flex flex-wrap items-end gap-3">
<div className="space-y-2">
<Label htmlFor="workspace-name">Name</Label>
<Input
id="workspace-name"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
required
className="w-64"
/>
</div>
<Button type="submit" disabled={savingWorkspace}>
{savingWorkspace ? "Saving..." : "Save"}
</Button>
</form>
</div>

<form onSubmit={handleInvite} className="flex flex-wrap items-end gap-3">
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { getAppName, getBrandName, buildTitle } from "@/lib/route-head";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { requireAuthSession, requireOrgAccess } from "@/lib/auth/helpers";
import { requireAuthSession, requireBrandAccess } from "@/lib/auth/helpers";
import { db } from "@workspace/lib/db/db";
import { prompts } from "@workspace/lib/db/schema";
import { eq, desc } from "drizzle-orm";
Expand All @@ -18,7 +18,7 @@ const getPromptsForEditing = createServerFn({ method: "GET" })
.validator(z.object({ brandId: z.string() }))
.handler(async ({ data }) => {
const session = await requireAuthSession();
await requireOrgAccess(session.user.id, data.brandId);
await requireBrandAccess(session.user.id, data.brandId);

// Fetch all prompts (including disabled) for editing
const brandPrompts = await db
Expand Down
Loading
Loading