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
+24 -6
View File
@@ -50,16 +50,26 @@ function MembersCard() {
},
});
const { mutate: changeRole } = useMutation({
const { mutate: changeRole, error: roleError } = useMutation({
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
onSuccess: invalidate,
// A rejected change (last owner, owner-only grant) leaves the select showing
// the value the server refused — refetch so the row snaps back to the truth.
onError: invalidate,
});
const { mutate: removeUser } = useMutation({
const { mutate: removeUser, error: removeError } = useMutation({
mutationFn: (userId: string) => api.deleteOrgUser(userId),
onSuccess: invalidate,
});
const actionError = (roleError ?? removeError) as Error | null;
// The server lets only an owner grant or change the owner role. Mirror that
// here so admins aren't offered controls that can only 403.
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
@@ -74,6 +84,12 @@ function MembersCard() {
</Button>
</div>
{actionError && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{actionError.message}
</div>
)}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
@@ -96,6 +112,8 @@ function MembersCard() {
<Tbody>
{users.map((u: OrgUser) => {
const isSelf = u.user_id === user?.user_id;
// Own row stays read-only, and only owners may act on owners.
const locked = isSelf || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
<Td>
@@ -103,7 +121,7 @@ function MembersCard() {
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{isSelf ? (
{locked ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
@@ -111,7 +129,7 @@ function MembersCard() {
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded-lg border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{ROLES.map((r) => (
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
@@ -126,7 +144,7 @@ function MembersCard() {
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!isSelf && (
{!locked && (
<Button
variant="ghost"
size="sm"
@@ -178,7 +196,7 @@ function MembersCard() {
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{ROLES.map((r) => (
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>