01c49ee56cd1da6786b0e1aad5b91033c9cf02a2
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 206a7c6eba |
Zeilenschutz auf vier Tabellen nachziehen, und den Zustand pruefen
Beim Neuaufbau der Datenbank aus den Migrationen am 26.08. blieben cost_centers, position_cost_centers, onboarding_tasks und offboarding_tasks ohne Row Level Security: sie verlassen sich auf den Ereignis-Trigger ensure_rls, den eine spaetere Migration erst anlegt. Ein Ereignis-Trigger wirkt nur nach vorne. Die Policies auf diesen Tabellen existieren und wurden nie ausgewertet — PostgreSQL befragt sie nur bei eingeschaltetem RLS. In jeder Aufstellung der Policies sah es richtig aus. Der CI-Job prueft ab jetzt den Zustand nach dem Lauf, nicht nur dass die Dateien durchlaufen. Genau in diesem Zwischenraum ist der Fehler durchgekommen. |
|||
| 029b63009f |
Den Postgres-Dienst beim Namen nennen, nicht ueber 127.0.0.1
Bei GitHub laeuft der Job auf dem Host und erreicht einen Dienst ueber den durchgereichten Port. Der Gitea-Runner setzt den Job selbst in einen Container: dort ist 127.0.0.1 der Container selbst, und dort horcht kein Postgres — 'Connection refused'. Beide Container haengen im selben Netz, also gilt derselbe Weg wie in docker-compose.yml: der Dienst wird ueber seinen Namen erreicht. |
|||
| a2f2f7075e |
Ohne sudo — im Runner laeuft alles als root
Das Abbild des Gitea-Runners ist schlanker als das von GitHub und bringt kein sudo mit. Es braucht auch keins: der Schritt laeuft ohnehin als root. |
|||
| 17c7badd16 |
psql im Runner bereitstellen
Der Job 'Migrationen auf leerer Datenbank' brach beim ersten Schritt ab: 'psql: command not found'. Das Abbild ubuntu-latest bringt keinen Postgres-Client mit, die Vorbereitung der Datenbank ruft ihn aber — deshalb lief der Job nie bis zu dem, wofuer es ihn gibt. Genau diese Pruefung haette die fehlenden Rechte der Anwendungsrolle gefunden, bevor sie beim Umzug auftraten: auf einer leeren Datenbank scheiterte die Anwendung mit 'permission denied for table profiles'. |
|||
| b87c8ad64c |
Remove Supabase
The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 77d9a95f7f |
Run the database in a container of our own
Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.
Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.
What the platform supplied, deploy/db-init now does:
- alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
RLS policies; a role that bypasses them would leave everything
working while showing too much, and nobody would notice.
- pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
used nowhere — no column default, no function calls uuid_generate_*.
- anon, authenticated and service_role as NOLOGIN placeholders. No
policy names them; they only carry grants the platform handed out,
and a data dump referencing them would fail to restore without them.
- A stub `auth` schema. The end state needs none of it — checked: no
foreign key, no policy, no column default refers to it. The June
2026 migrations do, and rewriting those would be falsifying history;
they describe what was true then.
The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.
Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.
psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.
SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.
The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.
Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|||
| d574d3c9d6 |
Aim the deploy at the runner that exists
The workflow asked for a `self-hosted` label. No runner on the instance offers one, so the run would have sat in "Waiting" forever — no error, no message, nothing to notice. The existing global runner elycon-runner-01 offers `docker` and `ubuntu-latest`, so both workflows now ask for `ubuntu-latest`, the same label ci.yml already used. That correction exposed a second thing the first version glossed over. act_runner starts a container per job; mounting the Docker socket into the *runner* does not put it in the *job*. Whether this job can reach the host's daemon depends on the runner's config.yaml, which is not visible from here — and the runner is global, so changing it affects every repository on the instance, not just this one. Rather than guess, the workflow now measures it in its first step and fails with the fix if it cannot: which config lines to add for the socket, or that SSH is the other way. Without that, the run would have died three steps later on a message nobody could act on. Both branches of the check were exercised: docker absent prints the first message, docker present with no reachable daemon the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| fc0989debb |
Deploy from a push, and start keeping track of migrations
A push to master now builds and restarts the application on the server: a Gitea Actions workflow on a self-hosted runner writes .env from the repository secrets, applies pending migrations, rebuilds the compose stack against the host's Docker daemon, and waits for the container's healthcheck before calling the run green. Without that last step a deploy counts as successful the moment the container *starts*, even if the app inside it dies immediately. Switching migrations on automatically turned up something that had to be fixed first: supabase_migrations.schema_migrations did not exist at all. Every one of the 65 migrations was unrecorded, because they have been applied by hand all along. An automatic `db push` would therefore have replayed all 65 against the live database — initial_schema and the OM cutover included. The database was checked against a spread of migrations first (it is at head), then baselined: all 65 recorded as applied without executing them. The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs only `pg`, which the project already ships, instead of downloading a CLI whose version drifts independently of this repository; and it does one thing — the missing files, in order, each in its own transaction — where `db push` also diffs schemas and may do more than that. Bookkeeping goes in the same table in the same shape the CLI uses, so `supabase db push` from a workstation still works and still skips what already ran. The workflow lives in .github/workflows, not .gitea/. Gitea reads .gitea/workflows and falls back to .github/workflows only when the former is absent — creating .gitea/ would have silently switched off ci.yml, with the run simply never appearing. Verified: both workflow files parse; the secret check names what is missing and refuses; values starting with "-" or containing "=" survive being written to .env; and the runner was exercised against the real database with a throwaway migration — applied once, skipped on a second run, and on a deliberate syntax error rolled back whole, recording nothing. Both probes were removed; the count is back to 65. Not verified: nothing has run on an actual Gitea runner — none is registered yet. DEPLOYMENT.md §5 covers registering one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| a66263a96e |
Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".
Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.
Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.
Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.
Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:
- Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
withUser() — und das öffnet immer eine Transaktion.
- set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
- Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
erzeugt zwei Fehler.
- Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.
tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.
Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.
Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
|
|||
| 8d978981b0 |
SVNR validation, CI, and a dependency/security pass
Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
|