> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hr-easy.nlead.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Security conventions

> The eight non-negotiable rules, and the tests every security fix must ship with.

These are hard rules. Violating any of them is an automatic blocking review finding.

## 1. Validate the URL scheme before any redirect

Before using a URL from a database field, a request parameter, or an external API
response in a redirect or an HTML page:

```typescript theme={null}
if (!/^https:\/\//i.test(url)) {
  return NextResponse.json({ error: "Invalid redirect target" }, { status: 400 });
}
```

A `javascript:` URI from the database is stored XSS. An `http://` URL leaks whatever
travels with it over plaintext. Only `https://` is acceptable.

## 2. No sensitive values in URLs or logged headers

Tokens, session identifiers and credentials travel in the **response body** — or as
HTML page content for a browser-side redirect — never in a `Location:` header or a
query string.

`Location: https://example.com?token=secret` hands the token to every proxy, CDN and
access log in the chain (CWE-598).

## 3. No plaintext credentials or tokens

Any field storing a credential, session token or API key is encrypted at the
application layer using the Key Vault utilities in `src/lib/crypto/`. A raw `String?`
holding a token is a defect unless it is explicitly approved and documented as a known
limitation with a linked follow-up ticket. A CI guard checks this.

## 4. Sanitise before interpolation

Never interpolate user- or environment-supplied strings into:

| Target          | Use instead                                                          |
| --------------- | -------------------------------------------------------------------- |
| GraphQL queries | Variables.                                                           |
| Shell commands  | `execFile` with an args array — never `exec` with a template string. |
| SQL             | Parameterised Prisma queries — never raw SQL with interpolation.     |
| HTML responses  | `JSON.stringify` plus unicode escaping of `<`, `>`, `/`, `&`.        |

## 5. Constant-time comparison for tokens

```typescript theme={null}
import { timingSafeEqual } from "crypto";
const safe = timingSafeEqual(Buffer.from(a), Buffer.from(b));
```

Never `===` for a token, secret or magic link. `===` short-circuits on the first
differing byte, which is a timing oracle.

## 6. Never trust an external API response

Validate the shape with Zod before persisting anything from SECO, bexio, Microsoft
Graph or any other upstream. An unexpected field from someone else's API is not a
reason to skip validation — it is the reason for it.

## 7. Ownership on every verb

For every route taking a resource ID, the ownership check applies to **all** handlers in
the file — `GET`, `PATCH`, `DELETE`, `POST` — not just the one you are editing.

## 8. `getCurrentLegalEntityId()` is read-scope only

Every write persists an explicit, tenant-validated `legalEntityId` via
`resolveWriteLegalEntity`. The ambient resolver is on a path to becoming cookie-driven;
stamping a write with its value would let a user-controlled cookie decide who legally
employs a person.

## Logging

`console.log`, `console.error`, `console.warn` and `console.debug` are **banned** in
`src/` outside test files, and `no-console` is an ESLint error.

| Need                                               | Import                                    |
| -------------------------------------------------- | ----------------------------------------- |
| Server-side structured logging                     | `@/lib/observability/services/logger`     |
| Logs that must reach App Insights with correlation | `@/modules/observability/services/logger` |
| Audit trail                                        | `@/lib/audit/logger`                      |

The `lib` logger is client-safe (browser and SSR) and deliberately does not import the
App Insights sink; the sink dynamically imports the Node-only package and is
server-only.

The single sanctioned exception is the logger sink itself, with a file-level disable.
Do not add new `eslint-disable no-console` directives.

## Every security fix ships with tests

A security fix without an attack-proof test is incomplete. Required:

<Steps>
  <Step title="The attack">
    Send the exact payload from the ticket — an IDOR ID swap, an injected string, a path traversal,
    a missing auth header — and assert the correct rejection status.
  </Step>

  <Step title="The auth boundary">Call the route with no session; assert `401`.</Step>

  <Step title="The permission boundary">
    Call it authenticated but under-privileged; assert `403`.
  </Step>
</Steps>

```typescript theme={null}
it("returns 403 when caller requests a resource owned by another tenant", async () => {
  mockSession({ userId: "user-a", tenantId: "tenant-a" });
  mockPrisma.resource.findUnique.mockResolvedValue({ id: "res-1", tenantId: "tenant-b" });
  const res = await GET(req, { params: Promise.resolve({ id: "res-1" }) });
  expect(res.status).toBe(403);
});
```

## Authentication specifics

* Roles are verified against the **database**, never from a cached token.
* Session and CSRF cookies are `Secure` by default; the opt-out exists only for plain
  `http://localhost`.
* Magic links are signed and expiring — 60 minutes by default, seven days for the
  onboarding link — and compared in constant time.
* The dev-login route requires `ENABLE_DEV_LOGIN="true"`, a non-production `NODE_ENV`,
  and a non-production host. All three.
* `ALLOWED_EMAIL_DOMAINS` should be set in production; falling back to the Entra tenant
  boundary alone is legacy behaviour and is logged as a warning.
