From ddd05994e05335b72bc1b9252342ec1080ccd89c Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 26 May 2026 10:25:40 +0200 Subject: [PATCH] docs(adr-0028): propose CI/CD + git hosting migration Gitea -> GitLab ADR-0015 framed Gitea Actions as a level-2 implementation explicitly slated for a GitLab migration within 6-18 months. That window opens now: vm-gitlab is provisioned, the team is about to scale, and the Gitea-specific friction points documented inline in .gitea/workflows/ ci.yml (paywalled gitleaks-action, github.com-bound trivy-action, manual curl+tar install dance) are accumulating maintenance tax. ADR-0028 records the platform shift. Architectural principles from ADR-0015 (trunk-based + squash, all-gates-blocking, thin YAML over portable scripts in pnpm + Nx, on-prem runners, Conventional Commits) carry over unchanged - only host / pipeline-file grammar / runner type / scan tooling change. Decision-only; 4-phase migration (mirror, parallel pipelines, cutover, cleanup) lands in follow-up PRs. Renumbering: ADR-0028 was a placeholder reference for the future Pleiades + Acteurs+ sync ADR. That ADR shifts to 0029; the 25 link references across ADR-0026 and ADR-0027 update in lockstep. CLAUDE.md roll-up adjusted accordingly. Status: proposed. Accept will be a separate small PR per the established pattern (see #219 for ADR-0026/0027 acceptance). --- CLAUDE.md | 8 +- .../0026-person-user-portal-data-model.md | 28 ++-- ...27-portal-side-organisational-hierarchy.md | 28 ++-- ...-migrate-cicd-and-git-hosting-to-gitlab.md | 158 ++++++++++++++++++ docs/decisions/README.md | 1 + 5 files changed, 191 insertions(+), 32 deletions(-) create mode 100644 docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md diff --git a/CLAUDE.md b/CLAUDE.md index 723aa0e..8014d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,15 +54,15 @@ The structural, security, observability, and quality choices are recorded as ADR - **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG ``/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md). - **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md). - **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<structure-code> / delegation:<dept> / region:<insee> / siege / unrestricted`, see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) for the `Structure.code` semantics). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md). -- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0028 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md). -- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 2–3-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0028 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md). +- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md). +- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 2–3-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md). - **Runtime:** Node.js latest LTS major. ## Repository status The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR. -ADRs 0001 → 0027 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, and portal-side identity + organisational hierarchy choices. ADR-0028 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition; until it ships, ADR-0026 + ADR-0027 implementation PRs together unlock the ADR-0025 stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return). **Shipped on `main`:** +ADRs 0001 → 0027 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, and portal-side identity + organisational hierarchy choices. ADR-0028 (migrate CI/CD + git hosting Gitea → GitLab self-hosted, supersedes ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged) and ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) are the next proposed additions. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:** - **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI. - **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)). @@ -76,7 +76,7 @@ ADRs 0001 → 0027 are accepted and cover the structural, security, observabilit - `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API. - `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline. -- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0028](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas. +- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0029](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas. - **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract. - **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope. - **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it. diff --git a/docs/decisions/0026-person-user-portal-data-model.md b/docs/decisions/0026-person-user-portal-data-model.md index 964b110..bb8e8a5 100644 --- a/docs/decisions/0026-person-user-portal-data-model.md +++ b/docs/decisions/0026-person-user-portal-data-model.md @@ -25,7 +25,7 @@ A naive "User table = anyone who ever signed in" model collapses these three pop - **Pré-provisioning** — a structure director must be able to assign a scope to a salarié before the salarié first signs in. The salarié needs a portal-side identity that pre-exists their first OIDC callback. - **Person-without-User** — a dossier is held by a bénéficiaire who may never sign in. The dossier still needs to reference a stable Person identifier; later, if the bénéficiaire signs up to portal-facing services, an account is overlaid on top of the existing Person record. -This ADR specifies the portal-side **identity** model. The organisational hierarchy that `@RequireScope` dereferences (`Region`, `Delegation`, `Structure`) is the sibling concern of [ADR-0027](#), kept on a separate timeline because the source-of-truth audit (cascade + acteurs_plus) reshaped that model after ADR-0026 was first drafted. The upstream **sync** of Person rows from Pléiades / Acteurs+ — together with the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) that attach to a Person — is [ADR-0028](#)'s territory. +This ADR specifies the portal-side **identity** model. The organisational hierarchy that `@RequireScope` dereferences (`Region`, `Delegation`, `Structure`) is the sibling concern of [ADR-0027](#), kept on a separate timeline because the source-of-truth audit (cascade + acteurs_plus) reshaped that model after ADR-0026 was first drafted. The upstream **sync** of Person rows from Pléiades / Acteurs+ — together with the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) that attach to a Person — is [ADR-0029](#)'s territory. ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`. @@ -33,8 +33,8 @@ ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`. - **`Principal.user.personId` must point at a real, stable identifier** — guards consuming `self`-scoped resources compare against it, and the AI service uses it (hashed) for audit-log joining per [ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md). - **Person-without-User** must be a valid state — Pléiades pre-provisioning, dossier beneficiaries, alumni. -- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0028](#)) lands invites churn. Mark them as deferred and keep ADR-0026 narrow. -- **Lazy User creation on first sign-in** — the OIDC callback creates the `User` row the first time it sees a new Entra `oid`. This avoids a Pléiades-side dependency before any sign-in works; Pléiades-driven pre-provisioning lands with ADR-0028 and updates the lookup path, not the schema. +- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0029](#)) lands invites churn. Mark them as deferred and keep ADR-0026 narrow. +- **Lazy User creation on first sign-in** — the OIDC callback creates the `User` row the first time it sees a new Entra `oid`. This avoids a Pléiades-side dependency before any sign-in works; Pléiades-driven pre-provisioning lands with ADR-0029 and updates the lookup path, not the schema. - **Safe-by-default dedup.** `entraOid` is the only natural key the v1 provisioner trusts. Email is an attribute, not an identifier — see "Lifecycle" below for why. - **No PII outside the boundary it belongs in.** `Person.firstName` / `lastName` are PII — they need the same redaction / hashing posture as the audit-log salt ([ADR-0013](0013-audit-trail-separated-postgres-append-only.md)). @@ -62,7 +62,7 @@ model Person { firstName String lastName String // Primary contact email. Indexed for operator-driven lookup (admin UI - // search, ADR-0028 reconciliation flow), NOT unique — two distinct + // search, ADR-0029 reconciliation flow), NOT unique — two distinct // humans genuinely can share an email (shared family alias, generic // info@ mailbox at a small partner organisation, error in an upstream // feed). A unique constraint would crash the first Pléiades import @@ -71,9 +71,9 @@ model Person { // Provenance: which upstream owns the row. v1: 'self-signin' // (lazy-created at OIDC callback), 'admin-ui' (manually entered // by an admin before first sign-in), 'seed' (test-tenant - // provisioning). ADR-0028 will add 'pleiades' and 'acteurs-plus'. + // provisioning). ADR-0029 will add 'pleiades' and 'acteurs-plus'. // The field is a free-form string in v1; promoting to an enum is - // an ADR-0028 decision once the sync sources are concrete. + // an ADR-0029 decision once the sync sources are concrete. source String // Upstream-system identifier (Pléiades matricule, Acteurs+ id). // Nullable because v1 sources do not provide one. @@ -122,7 +122,7 @@ model UserScope { // tolerates empty strings). value String @default("") // Provenance, same posture as Person.source. v1: 'admin-ui' or - // 'seed'. ADR-0028 adds 'pleiades' / 'acteurs-plus' and the + // 'seed'. ADR-0029 adds 'pleiades' / 'acteurs-plus' and the // reconciliation rules between admin overrides and upstream data. source String createdAt DateTime @default(now()) @@ -162,9 +162,9 @@ model UserScope { └──────────────────────────────────────────────────────────┘ ``` -**Why no email-based merging in v1.** Two distinct people genuinely can share an email (shared family alias, generic `info@` mailbox at a small partner organisation, error in an upstream feed). Auto-merging a fresh sign-in into an existing Person by email match would silently corrupt the golden record without surfacing a conflict. v1 takes the safe-by-default posture: `entraOid` is the only natural key the provisioner trusts; every other dedup happens on the explicit reconciliation flow that ships with ADR-0028. +**Why no email-based merging in v1.** Two distinct people genuinely can share an email (shared family alias, generic `info@` mailbox at a small partner organisation, error in an upstream feed). Auto-merging a fresh sign-in into an existing Person by email match would silently corrupt the golden record without surfacing a conflict. v1 takes the safe-by-default posture: `entraOid` is the only natural key the provisioner trusts; every other dedup happens on the explicit reconciliation flow that ships with ADR-0029. -**ADR-0028 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order: +**ADR-0029 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order: 1. by `Person.externalId` when the sign-in carries one (only when the federated identity exposes the upstream id, or when an admin has linked the two via the admin UI), 2. by an explicit operator-driven match flow on `Person.email` — surfacing the candidate match for confirmation rather than auto-merging, @@ -213,7 +213,7 @@ The two placeholders called out at the top of this ADR resolve: ### What ADR-0026 ships vs adjacent ADRs -| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0028 (sync + facets) | +| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0029 (sync + facets) | | ------------------------------------------------------------------------- | ---------------------------- | ------------------------ | ------------------------ | | `Person`, `User`, `UserScope` schema | ✅ | — | — | | `Region`, `Delegation`, `Structure` schema | — | ✅ | — | @@ -227,10 +227,10 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the ### Consequences - Good, because `Principal.user.personId` is finally a real, stable identifier — `@RequireScope({ kind: 'self' })` can compare against `Dossier.personId` end-to-end without "the entraOid happens to be the personId" gymnastics. -- Good, because Pléiades and Acteurs+ syncs (ADR-0028) drop into a known shape; their reconciliation logic (`externalId` precedence, operator-surfaced email-based candidates) extends the v1 provisioner rather than rewriting it. +- Good, because Pléiades and Acteurs+ syncs (ADR-0029) drop into a known shape; their reconciliation logic (`externalId` precedence, operator-surfaced email-based candidates) extends the v1 provisioner rather than rewriting it. - Good, because the admin UI scope-seeding flow (v1 manual entry per ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table") now has a target row to write against (`UserScope`). - Bad, because lazy-create-at-sign-in produces a Person row with sparse fields (`firstName` / `lastName` from the Entra `displayName` split, no postal address, no phone). Acceptable for v1 since dossiers / RH consumers do not exist yet; reconciliation with Pléiades data at sync time fills the gaps. -- Bad, because a user who signs in once with an Entra account and later gets imported from Pléiades produces two Person rows (one `self-signin`, one `pleiades`) that look like the same human. ADR-0028 handles the merge as an operator-confirmed flow; v1 simply accepts the duplicate as the cost of safe-by-default dedup. The duplicate is observable (same email) and easy to surface, not hidden. +- Bad, because a user who signs in once with an Entra account and later gets imported from Pléiades produces two Person rows (one `self-signin`, one `pleiades`) that look like the same human. ADR-0029 handles the merge as an operator-confirmed flow; v1 simply accepts the duplicate as the cost of safe-by-default dedup. The duplicate is observable (same email) and easy to surface, not hidden. - Neutral, because `UserScope.value` is an opaque string with no FK to ADR-0027's hierarchy tables. The write path validates against those tables; runtime read tolerates stale codes. Stale entries do not crash sign-in — they fail the `principalCoversResource` check on the resource that no longer exists, which is the correct behaviour. ### Confirmation @@ -247,7 +247,7 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the - Good, because portal-only fields (`lastSignInAt`, future a11y preferences, future audit flags) ride on `User`, not on the shared Person record that Pléiades may overwrite. - Good, because the ADR-0025 stubs resolve naturally — `personId` ≠ `userId` reflects the two-concern split that was already designed in. - Bad, because two tables means two queries on the OIDC callback hot path. Mitigation: a single Prisma `include` keeps it to one round trip. -- Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0028's territory and is easier to design with this shape than with Option A. +- Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0029's territory and is easier to design with this shape than with Option A. ### Option A — `User`-only @@ -279,5 +279,5 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the **Follow-up ADRs.** - **[ADR-0027](#) — Portal-side organisational hierarchy.** `Region` / `Delegation` / `Structure` schema (Structure with kind discriminator + nullable FINESS / SIRET keys, cascade-aligned). Sibling to ADR-0026, shipped at the same cadence. -- **[ADR-0028](#) — Pléiades + Acteurs+ syncs + facet schemas.** Specifies how `Person` rows are populated from upstream, how facets attach (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`), the reconciliation policy between sync-owned and admin-UI-owned fields, and how `user_scopes` is computed from facet relationships. +- **[ADR-0029](#) — Pléiades + Acteurs+ syncs + facet schemas.** Specifies how `Person` rows are populated from upstream, how facets attach (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`), the reconciliation policy between sync-owned and admin-UI-owned fields, and how `user_scopes` is computed from facet relationships. - **A future ADR — time-bound roles.** `UserScope.expiresAt` already exists; the corresponding facet-level mechanism (interim director for two months, treasurer mandate from 2026 to 2029) lands when the use case is concrete. diff --git a/docs/decisions/0027-portal-side-organisational-hierarchy.md b/docs/decisions/0027-portal-side-organisational-hierarchy.md index 7708f40..2d43d67 100644 --- a/docs/decisions/0027-portal-side-organisational-hierarchy.md +++ b/docs/decisions/0027-portal-side-organisational-hierarchy.md @@ -21,15 +21,15 @@ That assumption is wrong, and was caught before merge. The two APF systems that A portal model that pins `Etablissement.finess` as PK therefore **excludes ≥ 30 % of APF's real structure inventory** by construction. The fix is not "make FINESS nullable on Etablissement" — that just smuggles the discriminator into the absence-of-value semantics. The fix is to adopt cascade's discriminator-based model directly. -This ADR specifies the `Region` / `Delegation` / `Structure` schema that the portal persists, and the seeding posture for v1. The portal-side **sync** of these rows from cascade / Pléiades / Acteurs+ is [ADR-0028](#)'s territory. +This ADR specifies the `Region` / `Delegation` / `Structure` schema that the portal persists, and the seeding posture for v1. The portal-side **sync** of these rows from cascade / Pléiades / Acteurs+ is [ADR-0029](#)'s territory. ## Decision Drivers - **All APF structures must be representable**, not just the medico-social ones. Antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège — every category needs a row. - **Externally-meaningful identifiers must round-trip** through URLs (`/api/structures/<code>`) and scope literals (`etablissement:<code>`) without server-side translation. For medico-social structures the FINESS already plays this role; for the rest we adopt the cascade/acteurs_plus posture of a portal-internal `code` string. - **No FK from `UserScope.value` to `Structure.code`.** ADR-0026 already commits to this — the value is an opaque string at the type level so historical scope rows survive structure decommissioning. Validation happens at the admin-UI write path, not at the database level. -- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0028's cascade sync will overwrite it. -- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0028 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset. +- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0029's cascade sync will overwrite it. +- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0029 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset. ## Considered Options @@ -44,7 +44,7 @@ Chosen option: **B — `Structure` with internal `code` PK + `kind` discriminato 1. it covers 100 % of APF's structure categories without smuggling a discriminator into nullable FINESS; 2. it keeps the scope-check hot path local (no remote call to cascade); -3. it leaves a clean extension surface for ADR-0028 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive; +3. it leaves a clean extension surface for ADR-0029 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive; 4. it preserves the externally-meaningful round-trip property: for medico-social structures we set `Structure.code = FINESS` at seed time, so `etablissement:0330800013` stays a readable scope literal where it can. ### Schema @@ -97,14 +97,14 @@ model Structure { // Unique when present. Cascade's StructureSourceFiness is the // long-term authoritative carrier; the portal keeps it inline // on Structure as a denormalised attribute for the v1 scope-axis - // checks. ADR-0028's sync owns the write path. + // checks. ADR-0029's sync owns the write path. finess String? @unique // SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure is // not registered with SIRENE (most antennes, dispositifs). // Unique when present. siret String? @unique // Pléiades payroll code (6 chars). NULL in v1 — populated by - // ADR-0028's Pléiades sync once it ships. + // ADR-0029's Pléiades sync once it ships. codePaie String? @unique // Parent delegation. NULL for structures not attached to a // delegation (siège, mouvement national, …). @@ -136,26 +136,26 @@ The v1 seed is intentionally small — only what the ADR-0026 test-tenant flow n - **Delegation** Gironde (`33`) — sole delegation for v1. - **Structure** rows: a handful of medico-social établissements (Bordeaux + complexe-merignac with `kind='medico_social'`, FINESS = code), one `siege` (no delegation, no FINESS), one `entreprise_adaptee` placeholder if the test matrix needs it. -The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed. +The full APF inventory ships once ADR-0029's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed. ### What ADR-0027 ships vs adjacent ADRs -| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0028 (sync + facets) | +| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0029 (sync + facets) | | ----------------------------------------------------------- | ------------------- | ---------------------- | ------------------------ | | `Region`, `Delegation`, `Structure` schema | — | ✅ | — | | Inline reference-data seed for the test tenant | — | ✅ | — | | Full APF inventory (~ ten thousand structures) | — | — | Cascade-sync population | -| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0028's sync needs | +| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0029's sync needs | | `Structure.codePaie` populated | — | nullable (column only) | ✅ | ### Consequences - Good, because every APF structure category fits the schema — antennes and dispositifs are first-class, not absence-of-FINESS edge cases. - Good, because the scope-axis check stays local (no remote call to cascade on the guard hot path). -- Good, because cascade's eventual sync (ADR-0028) writes into the existing column set — no schema churn at sync time, just a write strategy. +- Good, because cascade's eventual sync (ADR-0029) writes into the existing column set — no schema churn at sync time, just a write strategy. - Good, because the medico-social FINESS round-trip (`/api/structures/0330800013`) and scope-literal readability (`etablissement:0330800013`) are preserved for the structures that have a FINESS. - Bad, because `Structure.code` semantics differ across kinds — sometimes it's a FINESS, sometimes a portal-internal slug. Operators reading the table need to consult `kind` to interpret `code`. Mitigation: the admin-UI scope-seeding screen ([ADR-0026](0026-person-user-portal-data-model.md) PR 2) shows `(code, name, kind)` together, not just the code. -- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0028 picks it up alongside the sync. +- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0029 picks it up alongside the sync. - Bad, because the scope kind name `etablissement` no longer matches its value semantics (a `Structure.code` of any kind, not just `medico_social`). The matcher contract is documented; rename deferred to a possible ADR-0025 amendment. - Neutral, because `UserScope.value` has no FK to `Structure.code`. ADR-0026 already commits to this; stale codes are tolerated at runtime and surface as a failed resource match, not a crash. @@ -180,14 +180,14 @@ The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline - Good, because the schema is the smallest possible thing. - Bad, because it excludes ≥ 30 % of APF's structure inventory (antennes, dispositifs, entreprises adaptées, …) by construction. - Bad, because making FINESS nullable to fix this smuggles the discriminator into absence-of-value semantics, which is harder to reason about than an explicit `kind` column. -- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0028) has to compress / discard information. +- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0029) has to compress / discard information. ### Option C — Full cascade replication - Good, because the schema is a perfect mirror — no information is lost at sync time, no consumer is ever blocked by an absent table. - Bad, because the v1 scope-axis check does not need Pole, Service, or self-referencing nesting — the join cost is paid every request for unused capability. - Bad, because the four per-source enrichment tables (`StructureSourceFiness`, `StructureSourceSirene`, …) reflect cascade's role as the multi-source aggregator; the portal is downstream of cascade, not parallel to it. Owning four sync feeds the portal does not consume is misallocation. -- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0028 picks up what its consumers need. +- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0029 picks up what its consumers need. ### Option D — Remote read against cascade @@ -205,7 +205,7 @@ The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline **Follow-up ADRs.** -- **[ADR-0028](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need. +- **[ADR-0029](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need. - **Possible future ADR-0025 amendment — rename scope kind `etablissement` to `structure`.** Triggered only if the vocabulary mismatch causes confusion in code review or operator-facing UIs; carries a closed-set catalogue rename + drift-gate update + all decorator literals. **Source-of-truth investigations.** The cascade and acteurs_plus audits that drove this ADR live alongside the project lead's working notes (gitignored). The audit findings summarised in §"Context" are the durable artefacts — the raw notes are not part of the repo. diff --git a/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md b/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md new file mode 100644 index 0000000..9ac02bb --- /dev/null +++ b/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md @@ -0,0 +1,158 @@ +--- +status: proposed +date: 2026-05-26 +decision-makers: R&D Lead +tags: [infrastructure, process] +--- + +# Migrate CI/CD + git hosting from Gitea to GitLab self-hosted + +## Context and Problem Statement + +[ADR-0015](0015-cicd-gitea-actions.md) chose Gitea Actions as the v1 CI/CD platform, explicitly framed as _"level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months"_. That window opens now: the infra team has provisioned `vm-gitlab` at `10.100.201.10`, the team is about to scale beyond a single dev, and several Gitea-specific friction points have surfaced and are documented inline in `.gitea/workflows/ci.yml` (no Trivy / gitleaks official action — manual install; GitHub-API-bound action defaults that 404 against Gitea — `actions/checkout` token fallback; act_runner discovery quirks). The decision recorded here formalises the platform shift. + +This ADR is **decision-only** — the actual migration ships across four follow-up PRs in §"Migration sequence" below. + +## Decision Drivers + +- **ADR-0015's explicit commitment.** The platform choice was always known to be temporary; we're inside the planned 6-18 month window. +- **`vm-gitlab` is already provisioned.** No infra wait. The host the migration targets is up. +- **Team scaling.** v1 is solo-developer; adding contributors needs better MR review affordances (inline suggestions, draft MRs, threaded discussions, reviewer assignment rules, MR templates). GitLab is materially better here than Gitea. +- **Built-in security scanning.** GitLab CE ships native SAST + dependency scanning + secret detection. Consolidates today's manual `Trivy + gitleaks` plumbing in `.gitea/workflows/ci.yml` into one tool with documented blocking thresholds — less inline YAML to maintain, fewer "official action paywalled" workarounds. +- **`act_runner` maturity ceiling.** It works, but it's a thin layer over `act` and has been the source of repeated setup-time friction (paywalled `gitleaks/gitleaks-action@v2`, github.com clone fallbacks in `aquasecurity/trivy-action`, missing standard runner image features). GitLab Runner with the Docker executor is the canonical CI execution model for the target platform. +- **Operational support.** GitLab is the infra team's preferred git/CI platform — operating it long-term has organisational support, Gitea does not. +- **Future Container Registry consumer.** When the BFF / SPA / docs site eventually ship Docker images (post-Phase-3), GitLab's built-in Container Registry is a natural target — currently no place to publish. + +## Considered Options + +- **Option A — Status quo (Gitea + `act_runner`).** Discarded: ADR-0015's commitment is explicit, friction is accumulating, and `vm-gitlab` is already provisioned. Inaction would be the deviation, not the default. +- **Option B — GitLab CE self-hosted on `vm-gitlab` (chosen).** On-prem (HDS / GDPR / likely ASVS L3 posture preserved), team's standard, immediate availability. +- **Option C — Forgejo self-hosted.** Forgejo is Gitea's actively-maintained fork with better open-source governance. UX-identical to today; would keep Gitea Actions on `forgejo-runner`. Considered but discarded: same feature gaps as Gitea (MR review affordances, native scanning), and the infra team's choice is GitLab — picking Forgejo would deviate without closing the friction. +- **Option D — Cloud SaaS (GitLab SaaS / GitHub Enterprise Cloud).** APF processes health + financial data; on-prem is materially preferable for the compliance posture. Cloud not pursued. + +## Decision Outcome + +Chosen option: **B — GitLab CE self-hosted on `vm-gitlab`**, because it is the only option that combines: + +1. **on-prem hosting** (compliance); +2. **enterprise-grade MR review affordances** (team scaling); +3. **native security scanning** that consolidates the current `Trivy + gitleaks` setup; +4. **a docker-native CI executor** (GitLab Runner with the Docker executor) replacing `act_runner`; +5. **immediate availability** — no infra wait. + +### What carries over from ADR-0015 (unchanged) + +The **architectural principles** of ADR-0015 are preserved verbatim. Only the implementation host changes: + +- Trunk-based development with squash-merge. +- Branch protection on `main`, all CI gates blocking. +- **Thin pipeline YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally.** This is the load-bearing call from ADR-0015 — it was made specifically so the CI platform could swap with low cost. ADR-0028 cashes in that bet. +- Self-hosted runners on the on-prem network. +- Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. +- Signed commits recommended (revisited at this migration — see §"Signed commits" below). +- Conventional Commits validated locally (hook) AND in CI (defense in depth). + +### What changes + +| Aspect | Before (ADR-0015) | After (ADR-0028) | +| -------------------- | ------------------------------------------------------- | -------------------------------------------------------------------- | +| Git host | `git.unespace.com` (Gitea) | `<vm-gitlab>` (GitLab CE) | +| Pipeline file | `.gitea/workflows/ci.yml` | `.gitlab-ci.yml` | +| Runner | `act_runner` (Docker Compose on dev host) | GitLab Runner with Docker executor on `vm-gitlab` | +| Dependency vuln scan | Trivy (manual install + `trivy-action` workaround) | GitLab Dependency Scanning (built-in) | +| Secret scan | gitleaks (manual install + paywalled-action workaround) | GitLab Secret Detection (built-in) | +| Bot account | `apf-portal-bot` (Renovate, Gitea) | `apf-portal-bot` (Renovate, GitLab) — same name, new auth token | +| MR review | Gitea PRs | GitLab MRs with templates, draft, threaded, suggestions | +| Conventional Commits | `pnpm ci:commits` against `origin/main` | unchanged — same script, different default-branch ref name if needed | + +### Migration sequence + +This ADR ships **decision-only**. The migration is implemented across four PRs after acceptance: + +| Phase | PR | Effect | +| ----- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | This ADR | Decision recorded; ADR-0015's "Gitea Actions" implementation choice flagged as superseded by §"Decision Outcome" here. | +| 1 | `mirror-and-bootstrap` | `git push --mirror gitlab` from each repo (`apf_portal`, `apf-ai-service` proto vendoring, later `cascade`/`acteurs_plus` when needed). GitLab side: groups, projects, branch protection (mirror Gitea's), MR templates, deploy keys, Renovate reconfigured for GitLab. **No `.gitlab-ci.yml` yet** — Gitea pipelines continue to gate. | +| 2 | `gitlab-ci-pipeline` | `.gitlab-ci.yml` lands alongside `.gitea/workflows/ci.yml` — **both run in parallel for ~1 calendar week** to validate parity. Replace the manual `Trivy + gitleaks` install in the scan job with GitLab's `SAST.gitlab-ci.yml` + `Secret-Detection.gitlab-ci.yml` includes. GitLab Runner started + registered on `vm-gitlab`. | +| 3 | `cutover` | Remotes flip in CLAUDE.md, READMEs, `docs/setup/01-dev-debian-vm-setup.md` §8.3 hand-off block. `.gitea/workflows/` deleted, `infra/ci-runners.compose.yml` deleted, the three `infra/data/runner-*/` directories deleted (or moved out of the repo). Gitea moves to read-only / archive (not decommissioned — old PR URLs in commit messages stay resolvable as historical artefacts). | +| 4 | `cleanup` | Stale Gitea references swept across docs (`docs/decisions/0015-…` annotated, this ADR amended if anything drifted during 1-3). `RENOVATE_PLATFORM`, `GITHUB_TOKEN` secret naming, and the workflow's `apf-portal-bot` exclusion rule reviewed. | + +Phases 1-4 run on the user's calendar; this ADR doesn't pin dates. + +### Signed commits + +ADR-0015 §"Signed commits recommended, revisited at GitLab migration" — that revisit happens here. The recommendation: + +- Once GitLab is the active host, **enable required signed commits** on `main` (GitLab's `commit signing` policy on the project's protected branch). GitLab supports both GPG and SSH signing keys. +- Pair with GnuPG agent forwarding (covered in `docs/setup/01-dev-debian-vm-setup.md` §8.5) so dev VM operations work without holding a private signing key on the VM. +- The `apf-portal-bot` (Renovate) service account needs a dedicated signing key — to be issued at PR 1 (mirror-and-bootstrap). + +### Consequences + +- **Good**, because ADR-0015's anticipated migration arrives within the committed window — the ADR record stays honest, no zombie commitments. +- **Good**, because MR review affordances improve materially (inline suggestions / drafts / threaded discussions / required-reviewer rules) — relevant from the moment a second contributor joins. +- **Good**, because GitLab's built-in scanning consolidates the dual `Trivy + gitleaks` setup into one configured tool with opinionated defaults. ADR-0015's "two paywalled-action workarounds" section of `.gitea/workflows/ci.yml` (~30 lines of inline rationale + `curl + tar` boilerplate) goes away. +- **Good**, because GitLab Runner is mature and well-documented relative to `act_runner`; removes a class of friction we've hit during runner ops. +- **Good**, because Container Registry is built-in — when the project eventually ships Docker images, a natural target exists without a new infra ask. +- **Bad**, because every dev must update remote URLs on every clone (`git remote set-url origin git@…`). Single-line operation but a coordination point at cutover. +- **Bad**, because existing references to Gitea PRs in commit messages and ADRs (`#213`, `#217`, `#219`, …) become 404s if Gitea is decommissioned. Mitigation: keep Gitea in **read-only / archive** mode rather than decommissioning — long-term cost is low (one VM at idle). +- **Bad**, because Renovate needs reconfiguration. Mitigation: Renovate has first-class GitLab support; the config translation is documented and lands in PR 1. +- **Bad**, because GitLab CE itself is a heavier piece of software to operate (Postgres + Redis + Sidekiq + nginx + …) than Gitea. Mitigation: infra team owns the platform's operability — same posture as `vm-dev`, just a different scope. +- **Neutral**, because the gates, scripts, and Nx orchestration are **unchanged**. Thin-YAML-over-portable-scripts was made specifically so the CI platform could swap with low cost. The bet pays off here. +- **Neutral**, because licensing: GitLab CE (the free / open-source edition) covers everything we need for v1 (CI/CD, MRs, Container Registry, basic SAST / Dependency / Secret scanning). The "Ultimate" tier features (advanced SAST, security dashboard, compliance pipelines) are paid — revisited if and when the compliance bar lands them in scope. + +### Confirmation + +- **Parity validation.** Phase 2 runs both pipelines in parallel on every PR for ~1 calendar week. Sign-off requires: every Gitea-Actions gate has its GitLab-CI counterpart, every commit that passes Gitea also passes GitLab, no false negatives observed. +- **Performance check.** GitLab CI's pipeline view shows total runtime. Acceptance bar: GitLab pipeline ≤ 1.5× current Gitea pipeline (the act_runner setup currently shares the dev host's warm Docker cache; GitLab Runner has a small per-job container-start tax — expected, not blocking). +- **Renovate sanity check.** Renovate creates at least one MR on GitLab successfully before phase 3 (cutover) — chosen MR being a small dependency bump on a non-critical lib so it's safe to merge or close on either platform. +- **Hooks parity.** The current pre-commit suite (`husky` + `lint-staged` + `commitlint`) is platform-agnostic — verified at PR 2 by running `pnpm prepare && git commit --allow-empty -m 'test'` on a fresh clone from GitLab. + +## Pros and Cons of the Options + +### Option B — GitLab CE self-hosted on `vm-gitlab` (chosen) + +- Good, because: see "Decision Outcome" above. +- Good, because GitLab is the infra team's preferred platform — operational support exists long-term. +- Good, because Container Registry is built-in (potential consumer when shipping Docker images). +- Good, because GitLab Pages is built-in — could replace the standalone VitePress hosting in [ADR-0022](0022-docs-site-vitepress.md) if we ever want to consolidate (decision-only — not in scope for this ADR). +- Bad, because GitLab CE is heavier to operate than Gitea. Mitigation: infra team ownership. +- Neutral, because licensing (CE covers v1 needs). + +### Option A — Status quo (Gitea) + +- Good, because: zero migration cost today. +- Bad, because ADR-0015 commitment to migrate. +- Bad, because: friction points (act_runner setup tax, MR review primitives, scan tool plumbing) compound as the team grows. +- Bad, because: `vm-gitlab` is already provisioned — staying on Gitea wastes the infra investment and leaves a parallel host to operate. + +### Option C — Forgejo self-hosted + +- Good, because: governance / fork direction is more aligned with open-source norms than Gitea. +- Good, because: migration from Gitea is trivial (Forgejo is a drop-in). +- Bad, because: same feature gaps as Gitea (MR review primitives, native scanning) — Forgejo is largely a same-feature-set fork with better governance, not a more capable product. +- Bad, because: infra team's choice is GitLab — Forgejo would be a per-project deviation. + +### Option D — Cloud SaaS + +- Good, because: zero operational burden for the platform itself. +- Bad, because: APF's data classification (health + financial) plus likely ASVS L3 makes on-prem materially preferable. +- Bad, because: corp egress policies likely require ingress / egress rules to cloud SaaS we don't have today. + +## More Information + +**Relationship to ADR-0015.** ADR-0028 supersedes the specific implementation choice _"Gitea Actions"_ in [ADR-0015 §"Decision Outcome"](0015-cicd-gitea-actions.md). The architectural principles in the rest of ADR-0015 (trunk-based + squash-merge, branch protection, all gates blocking, thin YAML over portable scripts, on-prem runners, signed commits, Conventional Commits, defense-in-depth) **carry over unchanged**. ADR-0015's frontmatter status stays `accepted`; a note at its top points at ADR-0028 for the platform shift. + +**Renumbering.** This ADR takes number `0028`. The previously-reserved placeholder for _"Pléiades + Acteurs+ syncs + facet schemas"_ — referenced in [ADR-0026](0026-person-user-portal-data-model.md) and [ADR-0027](0027-portal-side-organisational-hierarchy.md) as `ADR-0028` — shifts to **ADR-0029**. The `(#)` placeholder links in those two ADRs are updated in this PR so the chain stays consistent. + +**Phasing recap.** Decision-only. Implementation across four PRs: + +1. `chore(gitlab): mirror repos + bootstrap groups + branch protection + Renovate (no CI yet)` +2. `ci(gitlab): land .gitlab-ci.yml alongside .gitea/workflows/ — parallel run` +3. `chore(gitlab): cutover — flip remotes in docs, drop .gitea/workflows + infra/ci-runners` +4. `chore(gitlab): cleanup — sweep stale references + finalise signed-commit policy` + +**Follow-up ADRs.** + +- **[ADR-0029](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Previously numbered 0028 in [ADR-0026](0026-person-user-portal-data-model.md) / [ADR-0027](0027-portal-side-organisational-hierarchy.md) / `CLAUDE.md`. Renumbered in this PR's diff. Content unchanged from the original placeholder description. +- **A future ADR — required signed commits.** Optional, only if §"Signed commits" above turns out to need its own decision record (e.g. if APF's RSSI lands a specific policy on signing keys, key escrow, or revocation). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 17e7d30..bc98112 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -71,3 +71,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu | [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 | | [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account — portal-side identity model | accepted | `data`, `backend`, `security` | 2026-05-24 | | [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | accepted | `data`, `backend` | 2026-05-24 | +| [0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) | Migrate CI/CD + git hosting from Gitea to GitLab self-hosted | proposed | `infrastructure`, `process` | 2026-05-26 |