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:
1 parent
c1c1ae9c88
commit
0f15432045
10 files changed
+1599
-13
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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." });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user