fix: validate roles, guard last owner, scope bootstrap status

Security review of e70b2f0. The UI gating was correctly backed by
RequireRole everywhere; these are the missing validation gaps behind it.

- UpdateUserRole and createOrgUser accepted any role string verbatim, so
  an admin could self-promote to owner, create an owner outright, or set
  a junk role that silently stripped a user's access. Roles are now
  whitelisted, only an owner may grant or remove the owner role, and an
  actor cannot change their own.
- Neither demote nor delete guarded the last owner, so an org could reach
  zero owners. Both now refuse when no owner would remain, returning 409.
  Self-delete rejected.
- CountUsers counted across all orgs, so a locked-out org could never
  re-bootstrap once another tenant existed, and the unauthenticated
  bootstrap-status endpoint reported instance-wide state. It now answers
  per-org on an org host, falling back to global only on the apex.
- HandleMe repeats the middleware's host/org check; it sits outside the
  middleware so it can still return its own 401.
- Post-bootstrap now sends the new owner to their org host's login page.
  The session cookie is deliberately scoped to the exact host, so the old
  redirect landed them unauthenticated.
- AuthProvider renders an error state instead of mounting the shell with
  a null user when /auth/me fails for a reason other than 401.
- api.ts unwraps {"error": ...} so these messages render as text.
This commit is contained in:
2026-07-22 10:26:21 +01:00
parent e70b2f0e67
commit aa31cd8a10
9 changed files with 312 additions and 33 deletions
+43 -6
View File
@@ -12,20 +12,25 @@ const MIN_PASSWORD_LENGTH = 8;
* auth.hostSlug on the server). Build the new org's URL by prepending — or
* replacing — the leftmost label. Hosts that don't match that shape (localhost,
* bare IPs) have no per-org subdomain, so stay put.
*
* Setup runs on the apex, and the session cookie it sets is scoped to that
* exact host by design — org hosts must not share cookies. So the new owner is
* sent to the org host's *login* page to sign in there, which is what puts a
* session cookie on the host their org actually lives on.
*/
function orgUrlForSlug(slug: string): string {
if (typeof window === "undefined") return "/";
function orgLoginUrlForSlug(slug: string): string {
if (typeof window === "undefined") return "/login";
const { protocol, host } = window.location;
const [hostname, port] = host.split(":");
const parts = hostname.split(".");
if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/";
if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/login";
const rest = parts[0] === "vantage" ? parts : parts.slice(1);
if (rest[0] !== "vantage") return "/";
if (rest[0] !== "vantage") return "/login";
const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : "");
return `${protocol}//${newHost}/`;
return `${protocol}//${newHost}/login`;
}
export default function SetupPage() {
@@ -34,6 +39,7 @@ export default function SetupPage() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null);
// Setup is a one-shot route; once an owner exists it must not be reachable.
useEffect(() => {
@@ -50,7 +56,7 @@ export default function SetupPage() {
const { mutate: bootstrap, isPending, error } = useMutation({
mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }),
onSuccess: (res) => {
window.location.href = orgUrlForSlug(res.slug);
setCreated({ slug: res.slug, loginUrl: orgLoginUrlForSlug(res.slug) });
},
});
@@ -74,6 +80,37 @@ export default function SetupPage() {
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
if (created) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-text-primary">Organization created</h1>
<p className="mt-1 text-sm text-text-secondary">
Your owner account is ready. One more step to finish signing in.
</p>
</div>
<Card>
<p className="text-sm text-text-secondary">
{created.slug} has its own address, and sign-in is kept separate per organization. Continue
to your organization&apos;s sign-in page and log in with the email and password you just
chose.
</p>
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{created.loginUrl}
</code>
<a href={created.loginUrl} className="mt-5 block">
<Button type="button" variant="primary" className="w-full justify-center">
Go to sign in
</Button>
</a>
</Card>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">