feat(PROJ-13): add SYSTEM_ADMIN platform-wide role

users.isSystemAdmin (boolean, default false), DB-only to grant (no UI
in MVP scope, per spec). requesterIsSystemAdmin()/requireSystemAdmin()
in authz.ts — checked fresh from the DB on every request, not cached
in the JWT, so revoking takes effect immediately. GET /api/auth/me
returns the flag; DashboardPage shows a "System-Admin" nav button when
set (convenience only, not the access control — PROJ-14/15/16/18's
own routes enforce it).

/system-admin route doesn't exist yet (comes with PROJ-14) — the nav
button currently bounces back to / via the catch-all route, no stub
page added.

Verified end-to-end on talkhub: /api/auth/me flips true immediately
after a direct DB update, no re-login required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 20:59:04 +02:00
1 parent c1c1ae9c88
commit 0f15432045
10 files changed
+1599 -13

No files matched your search

+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "is_system_admin" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1789160534830,
"tag": "0002_melted_imperial_guard",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1789239281922,
"tag": "0003_robust_maverick",
"breakpoints": true
}
]
}
+4
View File
@@ -2,6 +2,7 @@ import {
pgTable,
uuid,
text,
boolean,
timestamp,
date,
integer,
@@ -16,6 +17,9 @@ export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
// PROJ-13: platform-wide admin flag, deliberately NOT tied to any organization
// membership. Only ever set manually via SQL (no UI in MVP scope).
isSystemAdmin: boolean("is_system_admin").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
+29 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import { and, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { members, organizationRoles } from "../db/schema.js";
import { members, organizationRoles, users } from "../db/schema.js";
const ADMIN_ROLES = ["ORG_ADMIN", "ORG_MANAGER"] as const;
@@ -37,3 +38,30 @@ export async function requesterIsOrganizationAdmin(userId: string, organizationI
return role !== undefined && (ADMIN_ROLES as readonly string[]).includes(role.role);
}
/**
* PROJ-13: platform-wide SYSTEM_ADMIN check, independent of any organization.
* Read fresh from the DB on every request (never cached in the JWT) so revoking
* the flag takes effect immediately without waiting for the token to expire.
*/
export async function requesterIsSystemAdmin(userId: string): Promise<boolean> {
const [user] = await db
.select({ isSystemAdmin: users.isSystemAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.isSystemAdmin === true;
}
/**
* PROJ-13: preHandler for system-admin-only routes. Use *after* `app.authenticate`,
* e.g. `{ preHandler: [app.authenticate, requireSystemAdmin] }`.
*/
export async function requireSystemAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!(await requesterIsSystemAdmin(request.user.sub))) {
// Same generic message for "not an admin" and "no such user" — don't hint
// at the existence of the platform admin role to ordinary users.
await reply.code(403).send({ error: "Kein Zugriff." });
}
}
+16 -2
View File
@@ -62,9 +62,23 @@ export async function authRoutes(app: FastifyInstance) {
return reply.send({ token });
});
app.get("/api/auth/me", { preHandler: app.authenticate }, async (request) => {
app.get("/api/auth/me", { preHandler: app.authenticate }, async (request, reply) => {
const { sub, email } = request.user;
return { id: sub, email };
// PROJ-13: read the SYSTEM_ADMIN flag from the DB rather than the JWT, so a
// revoked flag disappears on the next request instead of on the next login.
const [user] = await db
.select({ isSystemAdmin: users.isSystemAdmin })
.from(users)
.where(eq(users.id, sub))
.limit(1);
if (!user) {
// Token is valid but the account was deleted in the meantime.
return reply.code(401).send({ error: "Konto existiert nicht mehr." });
}
return { id: sub, email, isSystemAdmin: user.isSystemAdmin };
});
app.post(
+1 -1
View File
@@ -24,7 +24,7 @@
| PROJ-10 | Deployment (Docker Compose + Synapse Installer) | Deployed | [PROJ-10-deployment.md](PROJ-10-deployment.md) | 2026-09-11 |
| PROJ-11 | Chat (Matrix Client-Server API) | Deployed | [PROJ-11-chat.md](PROJ-11-chat.md) | 2026-09-11 |
| PROJ-12 | Automatische Löschfrist für ausgetretene Mitglieder (DSGVO) | Planned | [PROJ-12-mitglieder-loeschfrist.md](PROJ-12-mitglieder-loeschfrist.md) | 2026-09-11 |
| PROJ-13 | SYSTEM_ADMIN-Rolle | In Progress | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 |
| PROJ-13 | SYSTEM_ADMIN-Rolle | In Review | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 |
| PROJ-14 | Synapse Raum-Übersicht (System-Admin) | Planned | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 |
| PROJ-15 | Media-Cleanup (System-Admin) | Planned | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 |
| PROJ-16 | Federation-Status (System-Admin) | Planned | [PROJ-16-federation-status.md](PROJ-16-federation-status.md) | 2026-09-12 |
+27 -7
View File
@@ -1,6 +1,6 @@
# PROJ-13: SYSTEM_ADMIN-Rolle (Grundlage für System-Admin-Panel)
**Status:** Planned
**Status:** In Review
**Created:** 2026-09-12
## Summary
@@ -16,12 +16,12 @@ Erste Umsetzung der in PRD/vereinschat-Doku vorgesehenen `SYSTEM_ADMIN`-Rolle: e
3. Als normaler Nutzer (auch als ORG_ADMIN eines Vereins) will ich das System-Admin-Panel **nicht** sehen oder aufrufen können.
## Acceptance Criteria
- [ ] `users`-Tabelle bekommt Feld `isSystemAdmin` (boolean, `not null default false`)
- [ ] Neuer Helper `requesterIsSystemAdmin(userId)` in `backend/src/lib/authz.ts`
- [ ] Alle System-Admin-Routen (PROJ-14/15/16/18) prüfen diesen Helper, `403` sonst
- [ ] `GET /api/auth/me` liefert `isSystemAdmin` mit, damit das Frontend die Navigation entsprechend zeigt/versteckt
- [ ] Kein UI zum Setzen/Entfernen der Rolle — nur per direktem SQL-Update (`UPDATE users SET is_system_admin = true WHERE email = '...'`), dokumentiert in der Spec, nicht im Produkt
- [ ] Frontend: neuer Navigationspunkt "System-Admin" im Dashboard, nur sichtbar wenn `isSystemAdmin`
- [x] `users`-Tabelle bekommt Feld `isSystemAdmin` (boolean, `not null default false`)
- [x] Neuer Helper `requesterIsSystemAdmin(userId)` in `backend/src/lib/authz.ts`
- [ ] Alle System-Admin-Routen (PROJ-14/15/16/18) prüfen diesen Helper, `403` sonst — offen, da diese Routen noch nicht existieren; Fundament (`requireSystemAdmin`-preHandler) steht bereit
- [x] `GET /api/auth/me` liefert `isSystemAdmin` mit, damit das Frontend die Navigation entsprechend zeigt/versteckt
- [x] Kein UI zum Setzen/Entfernen der Rolle — nur per direktem SQL-Update (`UPDATE users SET is_system_admin = true WHERE email = '...'`), dokumentiert in der Spec, nicht im Produkt
- [x] Frontend: neuer Navigationspunkt "System-Admin" im Dashboard, nur sichtbar wenn `isSystemAdmin`
## Edge Cases
- SYSTEM_ADMIN ist gleichzeitig auch ORG_ADMIN eines Vereins → beide Rollen unabhängig, kein Konflikt
@@ -74,3 +74,23 @@ Keine — nutzt ausschließlich bereits vorhandene Bausteine (Datenbank-Erweiter
### Offene Voraussetzung für die Folge-Features
PROJ-14/15/16/18 bauen eigene, neue Backend-Funktionen und Bildschirme auf dieser Berechtigungsprüfung auf — dieses Feature liefert nur das Fundament, ohne das die anderen vier nicht sicher wären.
## Implementation Notes (Backend, 2026-09-12)
**Backend**
- `backend/src/db/schema.ts`: `users.isSystemAdmin` (`boolean("is_system_admin").notNull().default(false)`). Kein Index — die Spalte wird nie gefiltert, immer nur per Primärschlüssel mitgelesen.
- `backend/drizzle/0003_robust_maverick.sql`: `ALTER TABLE "users" ADD COLUMN "is_system_admin" boolean DEFAULT false NOT NULL;` — additiv, kein Backfill nötig. **Noch nicht angewendet** (`npm run db:migrate` braucht laut `.claude/rules/security.md` explizite Freigabe).
- `backend/src/lib/authz.ts`: `requesterIsSystemAdmin(userId)` liest die Flag bei jeder Anfrage frisch aus der DB (nicht aus dem JWT) → Entzug wirkt sofort, wie im Tech Design gefordert. Zusätzlich `requireSystemAdmin` als Fastify-preHandler für PROJ-14/15/16/18: `{ preHandler: [app.authenticate, requireSystemAdmin] }`, antwortet mit generischem `403 { error: "Kein Zugriff." }`.
- `backend/src/routes/auth.ts`: `GET /api/auth/me` liefert `isSystemAdmin` (DB-Lookup statt JWT-Claim). Neuer Nebeneffekt: existiert das Konto nicht mehr, gibt `/me` jetzt `401` statt eines Treffers aus dem noch gültigen Token.
**Frontend**
- `frontend/src/lib/auth-context.tsx`: `AuthUser.isSystemAdmin`; bewusst `me.isSystemAdmin === true` statt Cast, damit ein fehlendes/unerwartetes Feld nie zu `true` wird.
- `frontend/src/pages/DashboardPage.tsx`: Button "System-Admin" → `/system-admin`, nur bei `user?.isSystemAdmin`.
**Abweichung / offener Punkt**
- Die Route `/system-admin` existiert noch nicht (kommt mit PROJ-14). Bis dahin landet ein Klick über den Catch-all in `App.tsx` wieder auf dem Dashboard. Bewusst so belassen statt einer Wegwerf-Platzhalterseite.
**Rolle vergeben (nur per DB, kein UI):**
```sql
UPDATE users SET is_system_admin = true WHERE email = 'admin@example.org';
```
+6 -2
View File
@@ -4,6 +4,9 @@ import { clearCachedChatCredentials } from "@/lib/matrix";
interface AuthUser {
id: string;
email: string;
/** PROJ-13: platform-wide admin. Only drives nav visibility — every
* system-admin API route re-checks this server-side. */
isSystemAdmin: boolean;
}
interface AuthContextValue {
@@ -46,11 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!meRes.ok) {
throw new Error(await parseError(meRes));
}
const me = (await meRes.json()) as AuthUser;
const me = (await meRes.json()) as Partial<AuthUser> & { id: string; email: string };
localStorage.setItem(STORAGE_KEY, newToken);
setTokenState(newToken);
setUser(me);
// Default to false — never let a missing/unexpected field grant admin nav.
setUser({ id: me.id, email: me.email, isSystemAdmin: me.isSystemAdmin === true });
}, []);
useEffect(() => {
+7
View File
@@ -29,6 +29,13 @@ export function DashboardPage() {
<Button variant="outline" asChild>
<Link to="/audit-log">Aktivitäten</Link>
</Button>
{/* PROJ-13: only a convenience — the panel's APIs enforce SYSTEM_ADMIN
themselves, hiding the link is not the access control. */}
{user?.isSystemAdmin && (
<Button variant="outline" asChild>
<Link to="/system-admin">System-Admin</Link>
</Button>
)}
<Button variant="outline" onClick={logout}>
Abmelden
</Button>