#215: trusted reverse-proxy header / mTLS client-certificate path
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
For perimeters that authenticate before the application (ADR 0021 §4). Off unless BOTH AUTH_PROXY_HEADER and AUTH_PROXY_TRUSTED_PEERS are set — nothing about the header is guessed. The peer check runs against the TCP peer address only (a forwarded header is attacker-influenced): a request carrying the header from any other peer is rejected outright and audited as auth.proxy_rejected (catalogue v1.4) — that is a spoof attempt, not a misconfiguration — even when a valid session cookie rides along. From a trusted peer the header IS the identity; a session cookie never escalates beyond it; with the feature off the header is inert. Mapping is explicit (AUTH_PROXY_MAP: username or e-mail); deliberately no just-in-time creation — the header carries no verified address. The mTLS variant (AUTH_PROXY_MODE=mtls-dn) maps the configured attribute (default CN) out of the certificate subject DN the TLS terminator forwards, under the same peer rules. Session-less proxy requests key the read trail per user (user:<id>). The trust boundary is stated in security.md (the section an assessor reads closest), the VS-NfD security documentation and the hardening guide's deploy table. Tests cover all four decisions: off = inert, trusted peer authenticates (username and DN mapping), untrusted peer rejected + audited, no escalation past a session cookie. Refs #215. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
parent
5796b7a5dd
commit
4c7f001cab
@ -21,6 +21,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'auth.login_failed': { severity: 'warning' },
|
'auth.login_failed': { severity: 'warning' },
|
||||||
'auth.login_succeeded': { severity: 'info' },
|
'auth.login_succeeded': { severity: 'info' },
|
||||||
'auth.password_reset': { severity: 'notice' },
|
'auth.password_reset': { severity: 'notice' },
|
||||||
|
'auth.proxy_rejected': { severity: 'warning' },
|
||||||
'auth.signup': { severity: 'info' },
|
'auth.signup': { severity: 'info' },
|
||||||
'backup.restore_requested': { severity: 'warning' },
|
'backup.restore_requested': { severity: 'warning' },
|
||||||
'backup.run_triggered': { severity: 'info' },
|
'backup.run_triggered': { severity: 'info' },
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type { User } from '@prisma/client';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { ProxyIdentityService } from './proxy-identity.service';
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
export const SESSION_COOKIE = 'dt_session';
|
export const SESSION_COOKIE = 'dt_session';
|
||||||
@ -84,6 +85,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
|
private readonly proxyIdentity: ProxyIdentityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@ -94,9 +96,16 @@ export class AuthGuard implements CanActivate {
|
|||||||
this.assertSameOrigin(request);
|
this.assertSameOrigin(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trusted-proxy identity first (issue #215): when the perimeter
|
||||||
|
// authenticates, its header IS the identity for this request — a
|
||||||
|
// session cookie riding along never escalates beyond it, and an
|
||||||
|
// untrusted peer carrying the header is rejected inside resolve().
|
||||||
|
const proxyUser = await this.proxyIdentity.resolve(request);
|
||||||
|
if (proxyUser) {
|
||||||
|
request.user = proxyUser;
|
||||||
|
} else if (rawToken) {
|
||||||
// Attach the user whenever the cookie is valid — public routes may
|
// Attach the user whenever the cookie is valid — public routes may
|
||||||
// still want to know who is asking.
|
// still want to know who is asking.
|
||||||
if (rawToken) {
|
|
||||||
const validated = await this.sessions.validate(rawToken);
|
const validated = await this.sessions.validate(rawToken);
|
||||||
if (validated) {
|
if (validated) {
|
||||||
request.user = validated.user;
|
request.user = validated.user;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { AuthTokensService } from './auth-tokens.service';
|
import { AuthTokensService } from './auth-tokens.service';
|
||||||
import { OidcController } from './oidc.controller';
|
import { OidcController } from './oidc.controller';
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
|
import { ProxyIdentityService } from './proxy-identity.service';
|
||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -19,6 +20,7 @@ import { SessionsModule } from './sessions.module';
|
|||||||
AuthService,
|
AuthService,
|
||||||
AuthTokensService,
|
AuthTokensService,
|
||||||
OidcService,
|
OidcService,
|
||||||
|
ProxyIdentityService,
|
||||||
// Global default-protected: every route needs a session unless it
|
// Global default-protected: every route needs a session unless it
|
||||||
// opts out with @Public().
|
// opts out with @Public().
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
|
|||||||
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const HEADER = 'x-auth-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted reverse-proxy authentication (issue #215, ADR 0021): off by
|
||||||
|
* default (header fully ignored), identity only from a trusted TCP peer, a
|
||||||
|
* spoofing peer rejected AND audited, no privilege escalation past a
|
||||||
|
* riding-along session cookie, and the mTLS variant mapping a forwarded
|
||||||
|
* certificate DN attribute.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('trusted-proxy identity (e2e, issue #215)', () => {
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'proxy identitaet 123';
|
||||||
|
|
||||||
|
const PROXY_ENV = ['AUTH_PROXY_HEADER', 'AUTH_PROXY_TRUSTED_PEERS', 'AUTH_PROXY_MODE'] as const;
|
||||||
|
|
||||||
|
async function bootApp(env: Partial<Record<(typeof PROXY_ENV)[number], string>>) {
|
||||||
|
for (const key of PROXY_ENV) delete process.env[key];
|
||||||
|
Object.assign(process.env, env);
|
||||||
|
return createTestApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUser(app: INestApplication, handle: string) {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: `${handle}-${suffix}`,
|
||||||
|
email: `${handle}-${suffix}@example.test`,
|
||||||
|
displayName: handle,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const key of PROXY_ENV) delete process.env[key];
|
||||||
|
await prisma.auditEntry.deleteMany({ where: { action: 'auth.proxy_rejected' } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the header entirely while the feature is off', async () => {
|
||||||
|
const app = await bootApp({});
|
||||||
|
try {
|
||||||
|
await makeUser(app, 'off');
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set(HEADER, `off-${suffix}`)
|
||||||
|
.expect(401);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates a trusted peer, maps by username, and never escalates past a session cookie', async () => {
|
||||||
|
const app = await bootApp({
|
||||||
|
AUTH_PROXY_HEADER: HEADER,
|
||||||
|
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const alice = await makeUser(app, 'alice');
|
||||||
|
const bob = await makeUser(app, 'bob');
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
const me = await api().get('/api/v1/auth/me').set(HEADER, alice.username).expect(200);
|
||||||
|
expect(me.body.id).toBe(alice.id);
|
||||||
|
|
||||||
|
// Unknown identity: authenticated by nobody.
|
||||||
|
await api().get('/api/v1/auth/me').set(HEADER, `ghost-${suffix}`).expect(401);
|
||||||
|
|
||||||
|
// A session cookie riding along never escalates beyond the header
|
||||||
|
// identity: bob's cookie plus alice's header acts as alice.
|
||||||
|
const login = await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: bob.username, password })
|
||||||
|
.expect(200);
|
||||||
|
const both = await api()
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Cookie', sessionCookieOf(login))
|
||||||
|
.set(HEADER, alice.username)
|
||||||
|
.expect(200);
|
||||||
|
expect(both.body.id).toBe(alice.id);
|
||||||
|
// Without the header the same cookie still works normally.
|
||||||
|
const cookieOnly = await api()
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Cookie', sessionCookieOf(login))
|
||||||
|
.expect(200);
|
||||||
|
expect(cookieOnly.body.id).toBe(bob.id);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and audits the header from an untrusted peer — even with a valid session', async () => {
|
||||||
|
const app = await bootApp({
|
||||||
|
AUTH_PROXY_HEADER: HEADER,
|
||||||
|
AUTH_PROXY_TRUSTED_PEERS: '203.0.113.9',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const carol = await makeUser(app, 'carol');
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
await api().get('/api/v1/auth/me').set(HEADER, carol.username).expect(403);
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'auth.proxy_rejected' },
|
||||||
|
orderBy: { at: 'desc' },
|
||||||
|
});
|
||||||
|
expect(audit?.details).toMatchObject({ header: HEADER });
|
||||||
|
|
||||||
|
const login = await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: carol.username, password })
|
||||||
|
.expect(200);
|
||||||
|
// The spoofed header poisons the request even alongside a valid
|
||||||
|
// cookie — rejecting is safer than guessing which identity wins.
|
||||||
|
await api()
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Cookie', sessionCookieOf(login))
|
||||||
|
.set(HEADER, carol.username)
|
||||||
|
.expect(403);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the configured DN attribute in mtls-dn mode', async () => {
|
||||||
|
const app = await bootApp({
|
||||||
|
AUTH_PROXY_HEADER: HEADER,
|
||||||
|
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
|
||||||
|
AUTH_PROXY_MODE: 'mtls-dn',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const dana = await makeUser(app, 'dana');
|
||||||
|
const me = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set(HEADER, `CN=${dana.username},OU=unit,O=example`)
|
||||||
|
.expect(200);
|
||||||
|
expect(me.body.id).toBe(dana.id);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
import type { AuthedRequest } from './auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted reverse-proxy authentication (issue #215, ADR 0021): the
|
||||||
|
* perimeter (proxy or mTLS terminator) authenticates and forwards the
|
||||||
|
* identity in a configured header; the application trusts that header ONLY
|
||||||
|
* when the request's TCP peer is on the configured allowlist.
|
||||||
|
*
|
||||||
|
* The trust boundary, stated plainly (security.md §External
|
||||||
|
* authentication): everything upstream of the configured peers is the
|
||||||
|
* operator's responsibility; the application's contribution is that the
|
||||||
|
* header is worthless from anywhere else — a header from an untrusted peer
|
||||||
|
* rejects the request outright and lands in the audit trail
|
||||||
|
* (`auth.proxy_rejected`), because someone is attempting a spoof.
|
||||||
|
*
|
||||||
|
* Deliberately NO just-in-time creation here: the header carries no
|
||||||
|
* verified e-mail, so accounts must already exist (the IdP/OIDC path or an
|
||||||
|
* admin creates them) and are mapped by username or e-mail — explicit
|
||||||
|
* configuration, never guessed.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ProxyIdentityService {
|
||||||
|
constructor(
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(ProxyIdentityService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enabled only with BOTH the header name and a non-empty allowlist. */
|
||||||
|
get enabled(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.config.env.AUTH_PROXY_HEADER && this.config.env.AUTH_PROXY_TRUSTED_PEERS.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the request's proxy identity, or null when the feature is off
|
||||||
|
* or the header is absent. Throws 403 (audited) for an untrusted peer
|
||||||
|
* carrying the header, 401 for an unknown identity.
|
||||||
|
*/
|
||||||
|
async resolve(request: AuthedRequest): Promise<User | null> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
const headerName = this.config.env.AUTH_PROXY_HEADER!.toLowerCase();
|
||||||
|
const raw = request.headers[headerName];
|
||||||
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const peer = normalizePeer(request.socket.remoteAddress ?? '');
|
||||||
|
const trusted = this.config.env.AUTH_PROXY_TRUSTED_PEERS.map(normalizePeer);
|
||||||
|
if (!trusted.includes(peer)) {
|
||||||
|
// A spoof attempt, not a misconfiguration: reject and evidence it.
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'auth.proxy_rejected',
|
||||||
|
details: { peer, header: headerName },
|
||||||
|
});
|
||||||
|
throw new ForbiddenException({ code: 'proxy_peer_untrusted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = this.extractIdentity(value);
|
||||||
|
if (!identity) throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
|
||||||
|
const user =
|
||||||
|
this.config.env.AUTH_PROXY_MAP === 'email'
|
||||||
|
? await this.users.findByEmail(identity)
|
||||||
|
: await this.users.findByUsernameOrEmail(identity);
|
||||||
|
if (!user || user.status !== 'ACTIVE') {
|
||||||
|
throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `plain`: the value is the identity. `mtls-dn`: the value is a client
|
||||||
|
* certificate subject DN as forwarded by the TLS terminator; the identity
|
||||||
|
* is the configured attribute (default CN). */
|
||||||
|
private extractIdentity(value: string): string | null {
|
||||||
|
if (this.config.env.AUTH_PROXY_MODE === 'plain') return value.trim() || null;
|
||||||
|
const attribute = this.config.env.AUTH_PROXY_DN_ATTRIBUTE.toLowerCase();
|
||||||
|
for (const part of value.split(/[,/]/)) {
|
||||||
|
const [key, ...rest] = part.split('=');
|
||||||
|
if (key?.trim().toLowerCase() === attribute) {
|
||||||
|
const extracted = rest.join('=').trim();
|
||||||
|
return extracted || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `::ffff:127.0.0.1` and `127.0.0.1` are the same peer. */
|
||||||
|
function normalizePeer(address: string): string {
|
||||||
|
return address.replace(/^::ffff:/i, '').trim();
|
||||||
|
}
|
||||||
@ -10,8 +10,13 @@ export function readActorOf(request: {
|
|||||||
user?: { id: string } | null;
|
user?: { id: string } | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}): ReadActor {
|
}): ReadActor {
|
||||||
return {
|
// Session-less authenticated requests (trusted-proxy identity, #215) key
|
||||||
actorId: request.user?.id ?? null,
|
// per user — the proxy re-authenticates every request, so the user is
|
||||||
sessionKey: request.sessionId ? `session:${request.sessionId}` : 'anon',
|
// the closest thing to a session the channel has.
|
||||||
};
|
const sessionKey = request.sessionId
|
||||||
|
? `session:${request.sessionId}`
|
||||||
|
: request.user
|
||||||
|
? `user:${request.user.id}`
|
||||||
|
: 'anon';
|
||||||
|
return { actorId: request.user?.id ?? null, sessionKey };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,20 @@ expect the application to trust a header or a client certificate.
|
|||||||
plus the explicit `GET /auth/oidc/link` flow (audited
|
plus the explicit `GET /auth/oidc/link` flow (audited
|
||||||
`auth.identity_linked`) is the documented linking rule.
|
`auth.identity_linked`) is the documented linking rule.
|
||||||
|
|
||||||
|
## Decisions taken in #215
|
||||||
|
|
||||||
|
- **Peer check against the TCP peer address only** — a forwarded
|
||||||
|
`X-Forwarded-For` is attacker-influenced and never consulted.
|
||||||
|
- **Untrusted peer + header ⇒ reject the whole request (403) and audit**
|
||||||
|
(`auth.proxy_rejected`), even when a valid session cookie rides along:
|
||||||
|
a poisoned request is rejected, not partially trusted.
|
||||||
|
- **Trusted peer + header ⇒ the header is the identity**; a session
|
||||||
|
cookie never escalates beyond it. No just-in-time creation — the header
|
||||||
|
carries no verified e-mail, so accounts come from OIDC or an admin.
|
||||||
|
- **mTLS is proxy-terminated**: the application never touches TLS; the
|
||||||
|
terminator forwards the certificate subject DN and the configured
|
||||||
|
attribute (default CN) is the identity, under the same peer rules.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Bootstrapping needs a documented answer: the first-run wizard creates a
|
- Bootstrapping needs a documented answer: the first-run wizard creates a
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
# Audit event catalogue
|
# Audit event catalogue
|
||||||
|
|
||||||
**Catalogue version 1.3 (2026-07-31; 1.3 adds `auth.identity_linked`,
|
**Catalogue version 1.4 (2026-07-31; 1.4 adds `auth.proxy_rejected`,
|
||||||
issue #214; 1.2 added `read_trail.pruned`, issue #224; 1.1 added
|
issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
||||||
`page.classification_*`, issue #205).**
|
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||||
|
issue #205).**
|
||||||
|
|
||||||
This is the operator-facing contract for the audit trail: every event id
|
This is the operator-facing contract for the audit trail: every event id
|
||||||
the application can emit, with its trigger, severity, actor/target
|
the application can emit, with its trigger, severity, actor/target
|
||||||
@ -64,13 +65,14 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
### Authentication (`auth.*`)
|
### Authentication (`auth.*`)
|
||||||
|
|
||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
| ---------------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------ | ------ | ------------------------------------- |
|
| ---------------------- | ----------------------------------------------------------------------------------------- | -------- | ------------------------------------ | ------ | ------------------------------------- |
|
||||||
| `auth.signup` | Account created via self-registration or OIDC just-in-time (issue #214) | info | the new user | — | `provider` (optional; absent = local) |
|
| `auth.signup` | Account created via self-registration or OIDC just-in-time (issue #214) | info | the new user | — | `provider` (optional; absent = local) |
|
||||||
| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — |
|
| `auth.email_verified` | E-mail double-opt-in completed | info | the verified user | — | — |
|
||||||
| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — |
|
| `auth.login_failed` | Login rejected (bad credentials) | warning | matched user, `null` if unknown name | — | — |
|
||||||
| `auth.login_succeeded` | Session created | info | the user | — | `provider` (optional; absent = local) |
|
| `auth.login_succeeded` | Session created | info | the user | — | `provider` (optional; absent = local) |
|
||||||
| `auth.password_reset` | Password changed via reset token | notice | the user | — | — |
|
| `auth.password_reset` | Password changed via reset token | notice | the user | — | — |
|
||||||
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
|
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
|
||||||
|
| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` |
|
||||||
|
|
||||||
### Access & membership (`grant.*`, `member.*`)
|
### Access & membership (`grant.*`, `member.*`)
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,24 @@ or sloppy plugin authors, compromised dependencies.
|
|||||||
is deliberately NOT implemented: sessions are short-bounded, and the
|
is deliberately NOT implemented: sessions are short-bounded, and the
|
||||||
claim-mapping revocation path (#217) plus the account-disable flag
|
claim-mapping revocation path (#217) plus the account-disable flag
|
||||||
cover the leaver case — recorded in ADR 0021.
|
cover the leaver case — recorded in ADR 0021.
|
||||||
|
- **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
|
||||||
|
the request's **TCP peer address** — never a forwarded header — is on
|
||||||
|
`AUTH_PROXY_TRUSTED_PEERS`. Off unless both are set; nothing about the
|
||||||
|
header is ever guessed. A request carrying the header from any other
|
||||||
|
peer is rejected outright (403) and audited (`auth.proxy_rejected`) —
|
||||||
|
that is a spoof attempt, not a misconfiguration. A session cookie
|
||||||
|
riding alongside the header never escalates beyond the header identity;
|
||||||
|
with the feature off the header is inert. Mapping is explicit
|
||||||
|
(`AUTH_PROXY_MAP`: the value is the local username or e-mail; no
|
||||||
|
just-in-time creation — the header carries no verified address). The
|
||||||
|
mTLS variant (`AUTH_PROXY_MODE=mtls-dn`) expects the TLS terminator to
|
||||||
|
forward the client-certificate subject DN in the same header and maps
|
||||||
|
the configured attribute (`AUTH_PROXY_DN_ATTRIBUTE`, default CN).
|
||||||
|
Everything upstream of the trusted peers — TLS termination, certificate
|
||||||
|
validation, header hygiene (the proxy MUST strip the header from
|
||||||
|
incoming traffic) — is the operator's platform responsibility.
|
||||||
- **Keycloak verification procedure** (repeatable): run
|
- **Keycloak verification procedure** (repeatable): run
|
||||||
`docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev`;
|
`docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev`;
|
||||||
via `kcadm.sh`: create realm `dorfteich`, a public client
|
via `kcadm.sh`: create realm `dorfteich`, a public client
|
||||||
|
|||||||
@ -38,7 +38,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_
|
|||||||
|
|
||||||
- [x] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007
|
- [x] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007
|
||||||
ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214
|
ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214
|
||||||
- [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
|
- [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
|
||||||
Client-Zertifikat · 2 AT · #215
|
Client-Zertifikat · 2 AT · #215
|
||||||
- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
|
- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
|
||||||
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
|
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
|
||||||
|
|||||||
@ -47,7 +47,7 @@ Settings-Cache ist in-process (operations.md).
|
|||||||
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
||||||
|
|
||||||
| Variable | Referenzwert | Warum |
|
| 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). |
|
| `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_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. |
|
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
|
||||||
@ -55,6 +55,7 @@ Settings-Cache ist in-process (operations.md).
|
|||||||
| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
|
| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
|
||||||
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
|
| `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. |
|
| `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). |
|
||||||
|
|
||||||
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
||||||
|
|
||||||
|
|||||||
@ -210,10 +210,14 @@ einzelne Sync-Frames. Anonyme Leser teilen sich den Marker `anon`
|
|||||||
'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
|
'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
|
||||||
Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
|
Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
|
||||||
2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
|
2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
|
||||||
der Proxy ist die einzige Eintrittsstelle. Der geplante
|
der Proxy ist die einzige Eintrittsstelle. Der optionale
|
||||||
Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die
|
Proxy-Header-/mTLS-Authentisierungspfad (#215, Default aus) legt die
|
||||||
Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin
|
Authentisierungs-Vertrauensgrenze an genau diese Stelle: der
|
||||||
trägt der Proxy nur Transport.
|
konfigurierte Header gilt NUR vom TCP-Peer der Allowlist
|
||||||
|
(`AUTH_PROXY_TRUSTED_PEERS`); von jedem anderen Peer wird die
|
||||||
|
Anfrage abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy
|
||||||
|
MUSS den Header aus eingehendem Verkehr strippen (Betreiberpflicht;
|
||||||
|
Details: security.md §External authentication).
|
||||||
3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
|
3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
|
||||||
sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
|
sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
|
||||||
Zugang dorthin.
|
Zugang dorthin.
|
||||||
|
|||||||
@ -168,6 +168,32 @@ export const apiEnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
||||||
/** Button label the login page shows, e.g. the agency SSO's name. */
|
/** Button label the login page shows, e.g. the agency SSO's name. */
|
||||||
OIDC_PROVIDER_LABEL: z.string().min(1).default('Single Sign-On'),
|
OIDC_PROVIDER_LABEL: z.string().min(1).default('Single Sign-On'),
|
||||||
|
/**
|
||||||
|
* Trusted reverse-proxy authentication (ADR 0021, issue #215) — for
|
||||||
|
* environments that terminate authentication (or mTLS) at the perimeter.
|
||||||
|
* OFF unless BOTH the header name and the peer allowlist are set: a
|
||||||
|
* trusted header is a loaded gun, so nothing about it is guessed. The
|
||||||
|
* peer check runs against the TCP peer address (never a forwarded
|
||||||
|
* header); a request carrying the header from any other peer is
|
||||||
|
* rejected and audited.
|
||||||
|
*/
|
||||||
|
AUTH_PROXY_HEADER: z.string().min(1).optional(),
|
||||||
|
AUTH_PROXY_TRUSTED_PEERS: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) =>
|
||||||
|
(value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((peer) => peer.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
/** How the header value maps to a local account: as its username or its
|
||||||
|
* e-mail address. No just-in-time creation — the account must exist. */
|
||||||
|
AUTH_PROXY_MAP: z.enum(['username', 'email']).default('username'),
|
||||||
|
/** `mtls-dn`: the header carries a client-certificate subject DN (as the
|
||||||
|
* proxy forwards it) and the identity is the configured attribute. */
|
||||||
|
AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'),
|
||||||
|
AUTH_PROXY_DN_ATTRIBUTE: z.string().min(1).default('CN'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user