Skip to main content

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.

RoleWhat it can do
AdministratorEverything, including creating, editing and deleting accounts.
ReaderOpen 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
Those logs do not survive the next deployment

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.

In dev, every deployment is a first installation

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 variable deploy.sh does 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_PASSWORD is 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​

VariableWho reads itRole
SESSION_SECRETthe frontendsigns the session cookie
INTERNAL_API_KEYthe frontend and the five backendscloses the APIs
ADMIN_INITIAL_PASSWORDthe seed, if it is setchooses 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.