#216: hard AUTH_LOCAL_ENABLED switch over every local credential flow #284

Merged
fable-5 merged 1 commits from issue-216-local-auth-switch into main 2026-07-31 13:35:55 +02:00
12 changed files with 332 additions and 64 deletions

View File

@ -21,6 +21,7 @@ import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard';
import {
AuthedRequest,
LocalCredentialFlow,
Public,
SESSION_COOKIE,
setSessionCookie,
@ -55,13 +56,14 @@ export class AuthController {
@Get('methods')
methods(): AuthMethodsView {
return {
local: true,
local: this.config.env.AUTH_LOCAL_ENABLED,
oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null,
};
}
@Public()
@Post('signup')
@LocalCredentialFlow()
@HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
@ -70,6 +72,7 @@ export class AuthController {
@Public()
@Post('verify-email')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail(
@ -80,6 +83,7 @@ export class AuthController {
@Public()
@Post('resend-verification')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification(
@ -93,6 +97,7 @@ export class AuthController {
@SetupExempt()
@Public()
@Post('login')
@LocalCredentialFlow()
@HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login(
@ -136,6 +141,7 @@ export class AuthController {
@Public()
@Post('forgot-password')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword(
@ -146,6 +152,7 @@ export class AuthController {
@Public()
@Post('reset-password')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword(

View File

@ -3,6 +3,7 @@ import {
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
SetMetadata,
UnauthorizedException,
createParamDecorator,
@ -22,6 +23,18 @@ const IS_PUBLIC_KEY = 'isPublic';
/** Marks a route as reachable without a session (login, signup, healthz…). */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
export const LOCAL_CREDENTIAL_KEY = 'isLocalCredentialFlow';
/**
* Marks a route as part of the LOCAL credential machinery (issue #216,
* ADR 0021): password login, signup, e-mail verification, password
* forgot/reset/change. With `AUTH_LOCAL_ENABLED=false` every marked route
* answers 404 (existence hidden, the switch precedent) and the
* enumeration fence in `local-auth-switch.e2e.db.test.ts` fails when an
* auth route is neither marked nor on its reviewed allowlist, so a new
* credential flow cannot ship unswitched by accident.
*/
export const LocalCredentialFlow = (): MethodDecorator => SetMetadata(LOCAL_CREDENTIAL_KEY, true);
export interface AuthedRequest extends Request {
user?: User;
sessionId?: string;
@ -90,6 +103,17 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthedRequest>();
// The hard local-auth switch (issue #216): marked credential routes
// disappear entirely — before any session or CSRF logic runs.
if (!this.config.env.AUTH_LOCAL_ENABLED) {
const isLocalFlow = this.reflector.getAllAndOverride<boolean>(LOCAL_CREDENTIAL_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isLocalFlow) throw new NotFoundException();
}
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
if (rawToken && MUTATING_METHODS.has(request.method)) {

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AppConfig } from '../config/app-config.service';
import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module';
import { UsersModule } from '../users/users.module';
@ -27,4 +29,21 @@ import { SessionsModule } from './sessions.module';
],
exports: [AuthTokensService, AuthService, OidcService],
})
export class AuthModule {}
export class AuthModule implements OnModuleInit {
constructor(
private readonly config: AppConfig,
private readonly oidc: OidcService,
private readonly proxyIdentity: ProxyIdentityService,
) {}
onModuleInit(): void {
// #216: local auth off without ANY external path means nobody can ever
// sign in — loudly stated at boot, because the operator will otherwise
// discover it at the login screen.
if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) {
new Logger(AuthModule.name).warn(
'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists',
);
}
}
}

View File

@ -0,0 +1,157 @@
import 'reflect-metadata';
import { INestApplication } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { LOCAL_CREDENTIAL_KEY } from './auth.guard';
import { AuthController } from './auth.controller';
import { OidcController } from './oidc.controller';
import { SessionsService } from './sessions.service';
/**
* The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false
* closes EVERY local credential flow with 404 enumerated, not assumed
* while sessions themselves, logout, and token issuance for
* externally-authenticated users keep working (the stated decision: PATs
* and feed tokens authorize API access under their own switches, they are
* not interactive sign-in). A fence asserts every auth route is either
* marked as a local flow or on the reviewed allowlist.
*/
describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
/** Every local credential surface — the enumeration the issue demands. */
const LOCAL_ROUTES: { method: 'post'; path: string; body: Record<string, unknown> }[] = [
{ method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } },
{
method: 'post',
path: '/api/v1/auth/signup',
body: {
username: `switch-${suffix}`,
email: `switch-${suffix}@example.test`,
displayName: 'x',
password: 'ein langes passwort 123',
locale: 'en',
},
},
{ method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } },
{
method: 'post',
path: '/api/v1/auth/resend-verification',
body: { email: 'x@example.test' },
},
{ method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } },
{
method: 'post',
path: '/api/v1/auth/reset-password',
body: { token: 'x', password: 'ein langes passwort 123' },
},
{
method: 'post',
path: '/api/v1/users/me/change-password',
body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' },
},
];
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
process.env.AUTH_LOCAL_ENABLED = 'false';
app = await createTestApp();
});
afterAll(async () => {
delete process.env.AUTH_LOCAL_ENABLED;
await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('answers 404 on every enumerated local credential route', async () => {
for (const route of LOCAL_ROUTES) {
const res = await api()[route.method](route.path).send(route.body);
expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`);
}
});
it('reports local:false so the login screen hides the form', async () => {
const res = await api().get('/api/v1/auth/methods').expect(200);
expect(res.body.local).toBe(false);
});
it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => {
// An externally-authenticated user is simulated by creating the session
// through the session service — exactly what the OIDC/proxy paths do.
const users = app.get(UsersService);
const user = await users.createUser({
username: `ext-${suffix}`,
email: `ext-${suffix}@example.test`,
displayName: 'External',
password: 'nie benutzt weil lokal aus',
locale: 'en',
});
await users.markEmailVerified(user.id);
const token = await app.get(SessionsService).create(user.id, undefined);
const cookie = `dt_session=${token}`;
const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
expect(me.body.id).toBe(user.id);
// Stated decision (#216): token issuance is API authorization, not
// interactive sign-in — it stays available under its own switches.
await api()
.post('/api/v1/users/me/api-tokens')
.set('Cookie', cookie)
.send({ name: `switch-${suffix}`, scope: 'read' })
.expect(201);
await api()
.post('/api/v1/users/me/feed-tokens')
.set('Cookie', cookie)
.send({ name: `switch-${suffix}` })
.expect(201);
await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204);
await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401);
});
it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => {
// Routes that must stay reachable with local auth off — reviewed here.
const allowlist = new Set([
'registration', // signup-mode discovery; harmless metadata
'methods', // the login screen's discovery endpoint
'logout', // ending a session is not a credential flow
'me', // session introspection
'login', // OidcController: IdP redirect
'link', // OidcController: explicit identity linking
'callback', // OidcController: IdP return leg
]);
for (const controller of [AuthController, OidcController]) {
for (const name of Object.getOwnPropertyNames(controller.prototype)) {
if (name === 'constructor') continue;
const handler = controller.prototype[name as keyof typeof controller.prototype] as (
...args: unknown[]
) => unknown;
const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined;
if (path === undefined) continue; // not a route
const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true;
expect(
marked || allowlist.has(path),
`${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`,
).toBe(true);
}
}
});
});

View File

@ -17,7 +17,7 @@ import {
updateProfileInputSchema,
} from '@dorfteich/shared';
import { AuthedRequest, toCurrentUser } from '../auth/auth.guard';
import { AuthedRequest, toCurrentUser, LocalCredentialFlow } from '../auth/auth.guard';
import { SessionsService } from '../auth/sessions.service';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
@ -58,6 +58,7 @@ export class UsersController {
}
@Post('change-password')
@LocalCredentialFlow()
@HttpCode(204)
async changePassword(
@Body(new ZodValidationPipe(changePasswordInputSchema))

View File

@ -68,17 +68,27 @@ export function LoginPage(): React.JSX.Element {
<a className="button button--block" href="/api/v1/auth/oidc/login">
{t('auth:oidc.signIn', { provider: methods.data.oidc.label })}
</a>
{methods.data.local && (
<p className="auth-card__separator" aria-hidden="true">
{t('auth:oidc.or')}
</p>
)}
</>
)}
{/* AUTH_LOCAL_ENABLED=false (#216): the local form and its
credential links disappear the api answers 404 there anyway. */}
{(methods.data?.local ?? true) && (
<>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
{unverified && !resent && (
<p className="form-banner">
{t('auth:login.resendHint')}{' '}
<button type="button" className="linklike" onClick={() => void resendVerification()}>
<button
type="button"
className="linklike"
onClick={() => void resendVerification()}
>
{t('auth:login.resendLink')}
</button>
</p>
@ -91,7 +101,11 @@ export function LoginPage(): React.JSX.Element {
<input type="text" autoComplete="username" {...form.register('usernameOrEmail')} />
</Field>
<Field label={t('auth:login.password')} error={form.formState.errors.password?.message}>
<input type="password" autoComplete="current-password" {...form.register('password')} />
<input
type="password"
autoComplete="current-password"
{...form.register('password')}
/>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:login.submit')}
@ -103,6 +117,8 @@ export function LoginPage(): React.JSX.Element {
<p className="auth-card__links">
{t('auth:login.noAccount')} <Link to="/signup">{t('auth:login.signupLink')}</Link>
</p>
</>
)}
</div>
);
}

View File

@ -81,6 +81,25 @@ expect the application to trust a header or a client certificate.
terminator forwards the certificate subject DN and the configured
attribute (default CN) is the identity, under the same peer rules.
## Decisions taken in #216
- **Deploy-level, not runtime**: the switch is the environment variable
`AUTH_LOCAL_ENABLED` (default true). A compromised Site Admin cannot
reopen the local path — the runtime-flip residual risk from the
consequences below therefore does NOT materialize (R-02 closed).
- **404 semantics** on every marked flow (login, signup, verification,
resend, password forgot/reset/change), enforced centrally in the auth
guard via the `@LocalCredentialFlow()` marker; an enumeration fence
fails when an auth route is neither marked nor on the reviewed
allowlist, so a new credential flow cannot ship unswitched.
- **Bootstrap**: complete the first-run setup (or the `SETUP_ADMIN_*`
pre-seed, which does not run through HTTP routes) BEFORE flipping to
false — the wizard needs no permanent exemption. The api warns at boot
when local auth is off and neither OIDC nor proxy auth is configured.
- **PAT and feed-token issuance stay available** to (IdP-)authenticated
sessions: they authorize API access under their own switches
(`api.enabled`, `feeds.enabled`), they are not interactive sign-in.
## Consequences
- Bootstrapping needs a documented answer: the first-run wizard creates a

View File

@ -73,6 +73,14 @@ or sloppy plugin authors, compromised dependencies.
is deliberately NOT implemented: sessions are short-bounded, and the
claim-mapping revocation path (#217) plus the account-disable flag
cover the leaver case — recorded in ADR 0021.
- **The hard local-auth switch (issue #216)**: `AUTH_LOCAL_ENABLED=false`
closes every local credential flow with 404 — login, signup, e-mail
verification, resend, password forgot/reset/change — enforced centrally
in the auth guard via a route marker with an enumeration fence.
Deploy-level on purpose (a compromised Site Admin cannot flip it back).
Sessions, logout and PAT/feed-token issuance for externally
authenticated users keep working; stored password hashes remain
(documented, ADR 0021). Bootstrap: complete setup before flipping.
- **Trusted-proxy / mTLS path (issue #215)** — for perimeters that
authenticate before the application. **The trust boundary, precisely:**
the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if

View File

@ -40,7 +40,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_
ausbauen), Keycloak als Referenz-IdP · 56 AT · #214
- [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
Client-Zertifikat · 2 AT · #215
- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
- [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
- [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 23 AT · #217

View File

@ -47,7 +47,7 @@ Settings-Cache ist in-process (operations.md).
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
| Variable | Referenzwert | Warum |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
@ -56,12 +56,14 @@ Settings-Cache ist in-process (operations.md).
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. |
| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). |
| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). |
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
Derzeit leer — `auth.local.enabled` ist mit #216 als `AUTH_LOCAL_ENABLED` (1.2) scharfgestellt.
| Schalter | Referenzwert (geplant) | Status |
| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.local.enabled` | `false` — lokale Passwort-Auth aus, Anmeldung nur über die Fremdauthentisierung der Behörde | ⏳ kommt mit #216 (M27); bis dahin bleibt lokale Auth der einzige Anmeldeweg und `auth.registrationMode=closed` + Session-Verkürzung sind die Kompensation. Zeile wird im #216-PR scharfgestellt. |
| -------- | ---------------------- | ------ |
## 2 Verifikations-Checkliste
@ -73,6 +75,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/public/v1/ponds # 40
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false)
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false)
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/v1/auth/login # 404 (AUTH_LOCAL_ENABLED=false)
curl -s https://HOST/api/v1/readyz # status ok
```

View File

@ -33,21 +33,21 @@ nachvollziehbar.
Empfänger.
- **Entscheidung:** Projektleitung, PR #271 / Issue #212, 31.07.2026.
## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar
## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar — ERLEDIGT (#216, 31.07.2026)
- **Risiko:** Bis #216 (M27) existiert kein Schalter
`auth.local.enabled=false`; die Anwendung führt eigene
Passwort-Konten, obwohl die Behördenumgebung Fremdauthentisierung
vorsieht. Zusätzlich ist noch offen, ob der Schalter zur Laufzeit
umschaltbar sein wird oder einen Neustart verlangt — das entscheidet
#216 und trägt es hier nach.
- **Warum akzeptiert:** Reihenfolge der Umsetzung (M26 vor M27);
produktiver VS-NfD-Betrieb beginnt erst nach M27.
- **Kompensation:** Referenzkonfiguration (`50-haertungsleitfaden.md`):
Registrierung geschlossen, kurze Sessions (12 h absolut / 2 h idle),
Argon2id-Hashes, Rate-Limits, Audit der Anmeldungen.
- **Entscheidung:** Projektleitung, Maßnahmenplan Rev. 2 (M27-Planung),
30.07.2026.
- **Status: geschlossen.** Der harte Schalter existiert als Deploy-Variable
`AUTH_LOCAL_ENABLED=false` (#214#216, ADR 0021): jeder lokale
Credential-Flow (Login, Signup, Verifikation, Passwort
vergessen/zurücksetzen/ändern) antwortet 404; Anmeldung läuft über
OIDC (#214) bzw. Perimeter-Auth (#215). Die im Risiko offene Frage
„Laufzeit oder Deploy-Ebene" ist zugunsten der Deploy-Ebene
entschieden — ein kompromittierter Site-Admin kann den lokalen Pfad
nicht wieder öffnen; damit entsteht KEIN Laufzeit-Restrisiko.
Verbleibender Hinweis (dokumentiert, ADR 0021): bestehende
Argon2id-Passwort-Hashes bleiben nach dem Umschalten in der Datenbank
stehen; ihre Löschung ist bewusst nicht Teil von #216.
- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026).
## R-03 Plugin-Hash-Pinning verschoben

View File

@ -177,6 +177,20 @@ export const apiEnvSchema = z.object({
* header); a request carrying the header from any other peer is
* rejected and audited.
*/
/**
* The hard local-authentication switch (ADR 0021, issue #216) the
* deploy-level realization of the planned `auth.local.enabled`. FALSE
* closes EVERY local credential flow with 404 (login, signup, e-mail
* verification, resend, password forgot/reset/change); authentication
* then comes exclusively from OIDC (#214) or the trusted proxy (#215).
* Deploy-level on purpose: a compromised Site Admin must not be able to
* reopen the local path at runtime. Bootstrap order: complete the
* first-run setup (or SETUP_ADMIN_* pre-seed) BEFORE flipping to false.
*/
AUTH_LOCAL_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
AUTH_PROXY_HEADER: z.string().min(1).optional(),
AUTH_PROXY_TRUSTED_PEERS: z
.string()