Security and accounts
SekoiaCraft is meant to be put online in front of a restricted community. Two boundaries answer each other there, and they do not protect the same thing:
- a session closes the interface: an account is needed to open a screen;
- a shared secret closes the five APIs: you have to know it to query them.
The second is the least visible and the most important. In dev, preprod and prod, the five backends are published on the Internet by Traefik, each on its own hostname: without that secret, anybody could read your communities, your datasets and your settings without ever seeing the login screen.
The accounts
Two roles, and not one more.
| Role | What it can do |
|---|---|
| Administrator | Everything, including creating, editing and deleting accounts. |
| Reader | Open every screen, change none of them. |
An account is closed (Close account) rather than deleted: deletion also erases the trace that it
existed, and its last-login date with it. A closed account stays in the list and can no longer sign
in.
The last active administrator is protected. Demoting, closing or deleting them is refused as long as no other one can take over — otherwise nobody could reopen the accounts screen.
The first sign-in
The administrator account is created on the first load of the reference data (pnpm db:seed, or the
migrate container in a deployed environment):
- address:
admin@sekoiacraft.local; - password: drawn at random and printed only once, in the output of that step.
That password exists nowhere else: the database only holds its bcrypt hash, and no file receives it. Write it down, sign in, and change it immediately from Configuration → Users.
On a workstation, it is read from the logs of the container that seeded:
docker compose -f infrastructure/docker/docker-compose.dev.yml logs migrate
On a deployed environment, you have to read it on the server, in the deployment directory
(/home/<SSH_USER>/sekoiacraft/<env> by default — one directory per environment, so dev and
preprod can share a host without sharing a .env):
ssh <SSH_USER>@<SSH_HOST>
cd ~/sekoiacraft/prod
docker compose -f docker-compose.prod.yml --env-file .env logs migrate
The pipeline does not bring them back — the password never appears in a GitLab job's output, and that is deliberate: job logs are retained and readable by every maintainer of the project.
And deploy.sh starts every deployment with a docker compose down, which removes the migrate
container and its logs with it. The reading window therefore closes at the next deployment. Add to
that the fact that the banner is only printed once in the life of the environment — the seed is
create-only, and later deployments merely note that the account exists.
In practice: take the password down right after the first go-live, or choose it yourself through the next section.
A commit on develop redeploys dev from scratch: deploy.sh removes the containers, the volumes
and the application images of the previous deployment before starting the new one. The database is
empty again, the administrator account is recreated, and its password is drawn again at every
commit — the one written down yesterday no longer opens anything. Set DEV_ADMIN_INITIAL_PASSWORD
(next section) and the account keeps the same password from one deployment to the next.
Preprod and prod keep their volumes: the account, like everything else, survives their deployments,
and prisma migrate deploy evolves their schema without losing anything.
Choosing the initial password from a vault
Set ADMIN_INITIAL_PASSWORD in the seed's environment: it is then used as-is, and never printed —
it already comes from a vault, writing it into logs would be taking it back out.
- On a workstation: one line in the root
.env, or in front of the command (ADMIN_INITIAL_PASSWORD=… pnpm db:seed). - In deployment: a GitLab variable
<ENV>_ADMIN_INITIAL_PASSWORD, marked “Masked”, under Settings → CI/CD → Variables. It is the only environment variabledeploy.shdoes not require: without it, the deployment proceeds normally and the password is drawn.
It is only read on the first installation. Setting it on an already-seeded environment changes nothing as long as the account exists — see the procedure below.
The initial password is lost
There is no “forgotten password”: nothing is sent by email, and replaying the seed never resets an
account in service (Accounts: administrator already present — unchanged). That would cut off access
behind its owner's back, and the new password would end up in the logs.
If another administrator can still sign in, the answer is immediate: have them open Configuration → Users and set a new password on the account concerned.
Otherwise, the recovery path is to remove the seeded account and let it be recreated. On the server, in the deployment directory:
cd ~/sekoiacraft/prod
# The .env written by CI carries POSTGRES_USER and POSTGRES_DB; the shell does not.
set -a; . ./.env; set +a
C="docker compose -f docker-compose.prod.yml --env-file .env"
# 1. Remove the seeded account.
$C exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-c "delete from app_user where email = 'admin@sekoiacraft.local';"
# 2. Replay the accounts seed alone.
$C run --rm migrate sh -c 'node prisma/seed/seed-app-users.ts'
Two things are worth knowing about those two lines:
- The second prints the banner in your own terminal, and not in the logs of a container the next
deployment will erase. That is the robust way to recover the password, and it works just as well
for a first installation. If
<ENV>_ADMIN_INITIAL_PASSWORDis set, the banner shows nothing: the password is the vault's, as intended. - The first bypasses the last-administrator safeguard, which lives in api-config and not in SQL. That is precisely why this is a recovery procedure and not a button on the screen: between the two commands, the installation may have no administrator at all. Chain them.
The same pair works on a workstation, with pnpm db:seed in place of step 2 — it only recreates what
is missing.
No password is written in the repository, and that is deliberate: a versioned admin/admin is known
to every reader of the code, production included, and it survives there all the longer because nobody
remembers it exists.
The shared secret between the frontend and the APIs
A single variable, the same everywhere: INTERNAL_API_KEY. The frontend sets it as an
x-internal-key header on each of its calls; the five backends check it on all their routes.
# A random value, to be set in the installation's .env
openssl rand -base64 32
What it authenticates is the caller, never a user. The backends have no notion of an actor: roles live in the frontend's session, and it is the frontend that refuses a write to a reader. An identity-bearing token would have no use here, and it would have to be revoked, rotated and have the clocks of five services that do not talk to each other kept in agreement.
Three behaviours are worth knowing:
- Without a key, the APIs are open, and they say so at startup (
INTERNAL_API_KEY is not set: this API accepts every request). That is deliberate: an installation being updated must not fall over in error before its operator has set the key. In dev, preprod and prod, however, it is the only thing closing the APIs — set it. - The health routes stay free (
/health,/health/ready,/health/stream). Docker's checks query them without a header; a refusal would make every container unhealthy, hence the deployment a failure. - Swagger stays displayed on
/api, but its “Try it out” calls require the header. The page's “Authorize” button lets you enter it.
What this security does not cover
Saying it is better than letting it be believed.
- Nothing is encrypted in the database — neither the intake keys, nor the feed key, nor the shared secret. The decryption key would live on the same host as PostgreSQL, which would only add one step for anyone who already has access to the database. Passwords, for their part, are not stored: only their bcrypt hash is, and it never leaves PostgreSQL.
- No second factor, no automatic rotation of the shared key, no sign-in log beyond the date of the last one.
- A role change takes effect on the next sign-in, or after twelve hours — the length of a session. The role is carried by the signed cookie, which avoids querying the database on every navigation. The accounts screen, however, always revalidates: a demoted administrator cannot give themselves a role back with a stale cookie.
- A community has one Sekoia API key, and it configures as well as it sends. It sends the inventories, and the Create in Sekoia buttons use it to create intakes — entity, module configuration, intake — and asset connectors — module configuration, connector, then its stop. It therefore carries the asset rights and the intake and connector (Symphony) configuration rights of its space; give it no more than what that community uses. Being the key of the daily inventories, it cannot be cleared once a session's intakes exist without stopping them: SekoiaCraft keeps it, unencrypted, like the other keys.
- The shared secret is the same for all five APIs. That is what makes it generic; it is also what makes changing it require restarting the six services together.
Summary of the variables
| Variable | Who reads it | Role |
|---|---|---|
SESSION_SECRET | the frontend | signs the session cookie |
INTERNAL_API_KEY | the frontend and the five backends | closes the APIs |
ADMIN_INITIAL_PASSWORD | the seed, if it is set | chooses the initial password |
All three are injected by infrastructure/scripts/deploy.sh from the GitLab variables
<ENV>_SESSION_SECRET, <ENV>_INTERNAL_API_KEY and <ENV>_ADMIN_INITIAL_PASSWORD. The first two
are required — a deployment stops and names them if they are missing — and appear in
.env.example.
The third is optional on both sides, and that holds together: an environment letting its initial
password be drawn must be able to deploy, and requiring it would break every installation that has
nothing to choose. It appears in no versioned .env either — the repository's environment contract
demands a non-empty value there, which would amount to publishing exactly the credential we
refuse to publish.