Skip to main content
Version: 0.1

Security

This page describes how the gateway authenticates people and programs, what each role may do and where that is enforced, how it protects secrets and records writes, and what it trusts. It is for whoever installs or administers a gateway. It describes the model as it is in version 0.1, with the gaps named at the end; the step-by-step pages are Users and roles, API keys and Identity providers.

Authentication​

Session cookies​

A person signs in with an email address and password (or through a sign-in provider) and gets a session cookie, scadex.session. Sessions last 7 days. The cookie is encrypted by ASP.NET Core cookie authentication, which proves it was not tampered with, but the session row in the database is the source of truth: on every request the gateway looks the session up, and a signed-out or expired session is rejected even if the browser still holds the cookie. The role claim is rebuilt from the database on each request as well, so a role change applies to the user's next request; the cookie never decides authorization.

The sign-in routes live under /api/auth and are the only anonymous routes:

RoutePurpose
POST /api/auth/sign-in/emailSign in with email and password; sets the cookie
GET /api/auth/get-sessionThe current session and user, or 401
POST /api/auth/sign-outEnds the session in the database and clears the cookie
POST /api/auth/sign-up/emailCreates the first account only; 403 afterwards

Passwords are stored as PBKDF2-HMAC-SHA256 hashes with 210,000 iterations and a 16-byte random salt, in a self-describing format (pbkdf2-sha256$iterations$salt$hash) so the iteration count can be raised later. A session's expiry is fixed when it is created, 7 days ahead; it is not extended by use.

AUTH_BASE_URL is the public address of the gateway (default http://localhost:3000). When it starts with https:// the cookie is marked secure, so a browser only sends it over TLS. Set it to the address people type in their browser. See Auth API.

A 401 from the API is a JSON body {"statusCode":401,"code":"UNAUTHORIZED","message":"Unauthorized"}; a 403 is the same shape with FORBIDDEN. There is no redirect to a login page.

Trusted origins​

AUTH_TRUSTED_ORIGINS is a comma-separated list of origins, https://app.example.com, with * allowed for a wildcard subdomain (https://*.example.com). It is the gateway's defense against cross-site request forgery, in place of a CSRF token:

  • Every request that is not GET, HEAD or OPTIONS and carries a cookie must have an Origin (or, failing that, Referer) header matching a trusted origin. Otherwise it is refused with 403 and a code of MISSING_OR_NULL_ORIGIN or INVALID_ORIGIN.
  • A /sync WebSocket upgrade must come from a trusted origin too, because browsers send cookies on cross-site WebSocket upgrades.
  • Requests without a cookie are not checked by this rule; they cannot ride on anyone's session.

With the list empty, no cookie-authenticated write and no /sync connection from a browser succeeds. Put the gateway's own public address in it, and any other site whose pages talk to the gateway with a session.

The first account​

Public sign-up (POST /api/auth/sign-up/email) only ever succeeds for the very first user, who becomes the admin, however the account is created. After that it answers 403 "Public sign-up is disabled" and only an admin can create users. Because of this, create the first account before the gateway is reachable from outside, for example through the loopback port: see Network exposure.

Sign-in providers​

An admin can add OpenID Connect providers so people sign in with an outside account. A provider's sign-in becomes a gateway account: a new user gets the provider's default role (viewer unless the provider says otherwise, and never admin), and an existing user is linked only when the provider allows linking and the existing account is not an admin. Nothing a provider says changes an existing person's role. See Identity providers.

API keys​

A program authenticates with Authorization: Bearer scx_.... The secret is shown once when the key is made and only its SHA-256 hash is stored. A key carries one role, viewer, operator or engineer; a key can never be admin, and that is checked both when the key is created and on every request, so a hand-edited row cannot grant it. A key stops working when it is revoked or when its expiry passes. Admins manage keys under GET|POST /api/v1/admin/api-keys and DELETE /api/v1/admin/api-keys/{id}. API-key requests carry no cookie, so the origin check does not apply to them. See API keys.

Authorization​

Roles​

A user has one role, and each role can do everything the one before it can:

RoleAdds
viewerRead everything below /api/v1 except users and the audit log, subscribe and replay on /sync, and change their own preferences
operatorWrite to tags: tag.write on /sync and POST .../tags/{id}/write; acknowledge, shelve and unshelve alarms
engineerConfigure tags, tag types (UDTs), screens and start screens, start, stop and restart modules, and call module endpoints that change something, which includes driver connections and browsing them
adminManage users and API keys, read the audit log, set up identity providers and alarm pipelines, install and remove modules, and set the project directory

Only an admin can create users (POST /api/v1/admin/users, default role viewer), change a role (PATCH /api/v1/admin/users/{id} with {role}) or delete one. Removing or demoting the last admin gets a 409. Upgrading a gateway from before roles existed makes every existing user an admin, so nobody is locked out; review them afterwards. The same upgrade signs everybody out once. See Users and roles.

Where roles are enforced​

  • Every controller route names a policy (role:viewer, role:operator, role:engineer, role:admin). The default and fallback policies are the viewer policy, so a route that names nothing is closed to anyone without a known role rather than open. A role the gateway does not know, say from a hand-edited database, allows nothing.
  • The role is read from the database on every request, not trusted from the cookie. An open /sync socket checks the stored role on every write, and closes within 30 seconds if the session ends or no longer holds a known role.
  • Module endpoints have no say in their policy: a GET needs viewer and any other method needs engineer. A module can mark an endpoint that writes tag values with .WritesTagValues(), which lets operators call it.
  • The dashboard hides or disables what a role cannot do, but only as a courtesy: the checks above are the enforcement. On a running screen a viewer does not see commands, and setpoints and other write widgets are read-only.

What roles do not cover​

Roles are not scoped to tags: a user who may write one tag may write every tag. Start screens are filtered in menus by role, which is a navigation convenience, not a permission; the screen and its tags are still readable by any viewer through the API.

The repository holds a draft of a future model (platform/docs/authorization-model-draft.md), carried over from the earlier Node gateway and not implemented in the .NET gateway. Its direction is scope-based access control: named selectors over tag paths and labels, capabilities such as tag.read and tag.write granted or denied per scope, with explicit denies evaluated first and default deny. It is a design note only; nothing on this page should be read as a promise of it.

Accountability​

Write audit log​

Every attempt to change a tag value is recorded: who, which tag, the value (cut to 4 KB) and the outcome (accepted, rejected, failed or pending). The row is inserted before the write is attempted, and a write that cannot be recorded is not attempted. Writes refused at /sync or by a policy for lack of a role never reach the log, because they never became writes; nor do browser values under @gateway.browser.*. Admins read it at GET /api/v1/admin/audit/tag-writes and in the tag's Audit tab. Nothing prunes the table yet. See Audit log.

Alarm actions​

Acknowledging, shelving and unshelving record the principal's id and name on the alarm and in the alarm history, so the alarm list and Alarm history say who did what. See Alarms.

Who scripts run as​

Every system.* call a script makes is checked against the role of the caller the script runs as, and refused with "needs the engineer role, and this script runs as viewer" when it falls short. A scheduled job (the cron module, and any module script) runs as the gateway's own account for that module, with the engineer role, and its writes are audited under the principal the module names, for example cron:Reset counters. Screen scripts run from a /sync socket and are refused while that socket is in a replay. The details per script kind are in Scripting.

Secrets at rest​

Module state, which includes driver passwords and broker credentials, is encrypted in SQLite with ASP.NET Data Protection, using a purpose per module. A stored row starts enc:v1:. Modules never see that: IModuleStateStore reads and writes plain JSON. On startup any row still in plaintext is encrypted, and the database is then rewritten and its WAL emptied so that the old text does not linger in free pages.

This protects against a leaked database file: a copied scadex.db, or a backup of only that file. It does not protect against someone holding the whole data volume, because the keys are stored unencrypted beside the database in DATA_DIR/keys. Nor does it reach backups taken before the upgrade that introduced it, which still hold the plaintext. If one of those may have leaked, change the passwords.

warning

Back up DATA_DIR/keys together with the database, ideally to somewhere else. Without those keys the stored module state cannot be decrypted; the module that owns it reports failed with an error saying so. Rows also cannot be read by a gateway older than this feature. See Backup and restore.

Module logs strip credentials before a line is kept, shown or written (password=, secret=, token=, api-key=, authorization: values, the password in scheme://user:password@host, and bearer tokens), but a module should still not log secrets, as nothing can recognise every shape of one.

Module trust​

Modules are trusted, in-process code. They run inside the gateway's process with its user and file access. The restricted route builder (IModuleEndpointRouteBuilder) prevents accidental routing and policy mistakes, such as an unsafe or duplicate pattern or an endpoint without a role, but it is not a security sandbox for untrusted assemblies. Install only modules you trust as you would trust the gateway itself.

What the gateway checks before a module's code runs:

  • A valid manifest that matches the code, and an SDK version this gateway provides.
  • When the module declares requiresLicense or carries a module.sig, a valid signature over its files and a valid signed grant. Grants are signed with the scadex.io private key and gateways trust only the public keys compiled in, so a customer cannot sign their own. A module that fails this is unlicensed, unsigned or tampered, its code is not started and its routes do not exist. See Licensing.
  • An uploaded package is unpacked beside the installed modules and refused if a path climbs out of the folder, it contains symbolic links, more than 5,000 files or more than 400 MB unpacked, or the upload is over 200 MB.

Installing and removing modules through the API needs the admin role. Starting, stopping and restarting needs engineer. A module can also be dropped into the /modules mount as a folder, with no API call at all; so write access to that folder is admin access to the gateway, and the file permissions on it matter as much as the admin password. See Modules.

The script sandbox​

Expression tags, screen scripts, the script console and module scripts run JavaScript in Jint, outside the gateway's process, in a small pool of Scadex.ScriptWorker processes that talk to it over stdin and stdout, one script per process at a time. Two are ready from startup and, on a machine with the cores for them, up to four when needed.

What a script cannot do:

  • Reach the CLR: no .NET types, no file or network access from the language.
  • Use eval or Function.
  • Run without bounds. Each run has a statement limit, a memory limit, a recursion limit and a CPU time limit, with a regex timeout to match: expressions get 50,000 statements, 16 MiB and 250 ms; screen scripts 500,000 statements, 32 MiB and 2 s; module scripts 2 million statements, 32 MiB and 5 s. A script's tag reads have their own limit of 5 s in all. A script stuck where Jint cannot check its limits is killed a second after its limit.
  • Keep state between runs: every run gets a fresh engine.

What a worker still is: a process running as the gateway's user, with the gateway's file access. Its environment holds none of the gateway's settings or secrets, but it is not a stronger security boundary than Jint itself. A script that crashes its worker (a stack overflow, or running it out of memory at a 256 MiB heap) ends only that worker; the worker is replaced and that script is refused for 5 seconds, doubling each time up to 5 minutes. Other scripts carry on in the remaining workers.

Who may write a script: expression tags and screen scripts are configuration, so they need the engineer role; cron jobs are a module's configuration and need the same. The writes a script makes go through the same path as any other write and are audited. See Scripting.

Network exposure​

The gateway listens on one HTTP port, 8080 in the appliance image (SCADEX_PORT), and serves the API, /sync and the dashboard from it. It does not terminate TLS. Put a reverse proxy in front of it for HTTPS, and then set AUTH_BASE_URL and AUTH_TRUSTED_ORIGINS to the public https:// address so cookies are secure and writes are accepted from the pages the proxy serves.

The order matters on a new install: public sign-up is open until the first account exists. Create the first (admin) account through the loopback port before the public route exists, then open the proxy. See Installation and Configuration.

The dashboard is served by the gateway itself, so for most installations one origin is enough in AUTH_TRUSTED_ORIGINS. A separate web app on the SDK, served from another origin, needs its origin added to the list before its writes and its /sync connection are accepted. See React app on the SDK.

The live socket​

/sync needs a signed-in session, and its upgrade must come from a trusted origin. An API key is accepted at the upgrade, but a key has no session for the gateway to re-check, so a socket opened with one is closed within 30 seconds and cannot write. Opening one needs the viewer role, which covers subscribing and replaying; tag.write needs operator and is checked against the stored role on every write. Each user may hold 32 sockets and each socket 10,000 subscriptions. A slow client loses its oldest queued frames rather than slowing the runtime, and one that falls too far behind is disconnected. An open socket is closed within 30 seconds of its session ending.

Admins can see every open socket, who holds it and what it subscribes to, under Gateway, Clients (GET /api/v1/admin/clients). See Clients and the /sync protocol.

Durability​

SQLite runs in WAL mode with synchronous=NORMAL, and commits do not checkpoint; a background task does, every 5 seconds. A gateway crash loses nothing, and the database cannot be corrupted this way. A power cut can lose the last few seconds of commits, which can include recent configuration changes and write audit rows. That is the same window JetStream has, which also syncs on an interval.

What is not there yet​

  • SAML sign-in. Providers are OpenID Connect only.
  • Group or claim mapping from a provider to a role. A provider has one default role for new users.
  • Tag-level or path-level permissions. The four roles apply to the whole gateway.
  • Definable roles. The four are fixed.
  • An OAuth2 authorization server: the gateway issues session cookies and API keys, not OAuth tokens.
  • Per-key scoping. An API key has a role, not a list of tags or routes.
  • Pruning of the write audit log.
  • Rate limiting or lockout of sign-in attempts. Nothing in the authentication code does this; the reverse proxy is the place to add it.