Locks · locksql
Configuring locksql
Profiles, PII rule sets, sharing a configuration and the company's managed configuration. Setting a project up: docker.md; what each setting does at run time: usage.md.
Profiles #
Profiles live in .locksql/config.toml at the project root (found by walking
up from the current directory) and in <user config dir>/locksql/config.toml.
A project profile wins over a user profile of the same name. No file ever
holds a secret: keys named like password, passwd, pwd, secret or
token, and DSNs with an embedded password, are refused.
Breaking change (v0.15). locksql no longer enforces transport security: the
tls*settings are passed to the driver, a profile with notlssetting gets the drivers' defaultpreferwhatever the host (it wasverify-fullfor a remote host since v0.4),production = trueno longer requires TLS, and no authentication method is refused because of the TLS mode. A plaintext connection gets one warning at session start. To keep verifying the server certificate, settls = "verify-full".
[profiles.uat]
engine = "mariadb" # mariadb | mysql | postgres | sqlite
host = "db.uat.example.com" # or path = "app.db" for sqlite
port = 3306 # default 3306, or 5432 for postgres
user = "" # empty: asked at console start
database = "" # empty: chosen per query (--db)
credentials = "ask" # ask | keychain | vault
tls = "verify-full" # disable | prefer (default) | require | verify-ca | verify-full, as libpq's sslmode
tls_ca = "" # optional PEM bundle replacing the system roots; "~/db-ca.pem" is in the console account's home
credentials_ttl = "20m" # optional: ask for the secret again
tier = "read" # read | write | ddl | admin
production = false
detectors = ["email", "phone", "iban", "card", "be_niss"]
[profiles.uat.limits]
statement_timeout = "30s"
explain_rows_warn = 100000
explain_rows_refuse = 1000000
max_rows = 200
max_cell_chars = 200
max_output_bytes = 65536
k_anonymity = 5
explain_cost_refuse = 0 # engine cost units; 0 = off
idle_timeout = "20m" # the connection closes after this long idle
max_session = "4h" # ... and after this long in all
grant_max_duration = "1h" # the longest grant; "0" turns grants off
grant_max_statements = 200 # a grant's budget at most: statements,
grant_max_rows = 10000 # rows returned
grant_max_bytes = 1048576 # and bytes returned
tickets_per_window = 30 # tickets a human decides per window
ticket_window = "5m"
max_pending = 30 # tickets pending at once
With credentials = "vault" the console runs vault read -format=json <vault_path> at every connection, as the console account and with its
environment (VAULT_ADDR), and connects with the username and password
of the secret. The console asks for the Vault token at start and keeps it in
memory for the session. Nothing is stored. See
Credentials from HashiCorp Vault.
[profiles.reporting]
engine = "postgres"
host = "db.internal"
tls = "verify-full"
credentials = "vault"
vault_path = "database/creds/reporting-ro" # or a KV secret: "secret/data/reporting"
A database reachable only from a bastion gets an ssh table: the console
opens the SSH connection itself, with no local listening port, and verifies
the bastion against the console account's ~/.ssh/known_hosts (asking on
first use, refusing a changed key). host is then the database as seen from
the bastion; the bastion-to-database leg is protected by tls only, unless
the database runs on the bastion itself. See
Reaching a remote server through SSH.
A database that publishes no port (an internal Docker network, a server
bound to its container's loopback) is reached with proxy_command, an argv
whose stdin and stdout carry the connection, such as
["docker", "exec", "-i", "shop-db", "socat", "-", "TCP:127.0.0.1:3306"];
see Databases that publish no port.
[profiles.prod]
engine = "postgres"
host = "db.internal" # as seen from the bastion
tls = "verify-full"
production = true
[profiles.prod.ssh]
host = "bastion.example.com"
port = 22 # default 22
user = "deploy"
auth = "key" # key | agent | password
key = "~/.ssh/id_ed25519" # auth = "key" only; the console account's home
credentials = "ask" # passphrase or SSH password; default: the profile's
All profile keys
| Key | Default | Notes |
|---|---|---|
engine |
required | mariadb, mysql, postgres, sqlite |
host, port |
required host; port 3306 / 5432 | a host starting with / is a Unix socket (MariaDB/MySQL) |
path |
required for sqlite | relative to the project root; the file is never created |
user |
asked at start | with credentials = "vault": the secret's username; set it only for a secret without one |
database |
none | the only database served (and scanned for PII); empty: every database the account can see, chosen per query with --db |
credentials |
ask |
keychain (not in a container) offers to save the secret after the first successful login; locksql forget --profile P removes it. vault reads the login from vault_path at every connection |
vault_path |
required with credentials = "vault" |
the Vault path read with vault read, such as database/creds/<role> or secret/data/<name>; any change is a loosening |
credentials_ttl |
none | a duration of at least 1m ("20m", "1h"): the connection is closed and the secret asked again once it is that old |
tier |
read |
highest statement class allowed |
production |
false |
stricter limits, production banner at console start, and the typed profile name when its policy is first approved or loosened |
detectors |
email, phone, iban, card |
also be_niss, fr_nir, nl_bsn, us_ssn and phone_loose; [] disables them. phone masks + and 8 to 15 digits, 00 and 10 to 17 digits, or a national number with a trunk 0 (9 to 11 digits, a first group of at least 2 digits); phone_loose adds national numbers without a trunk 0 ((415) 555-2671, 612 345 678: 9 to 11 digits in at least two groups, the first of 2 to 4) and also masks some grouped amounts (123 456 789). Every detector reads U+00A0, U+202F and U+2009 as a space and full-width digits as digits |
pii_rules |
the profile name, in lower case | the PII rule set, pii/<set>.toml; 1 to 64 lower-case letters, digits, _, - or .; profiles on isomorphic databases share a set by naming it; any change is a loosening |
limits.statement_timeout |
30s (production 10s) | |
limits.explain_rows_warn |
100 000 (production 20 000) | |
limits.explain_rows_refuse |
1 000 000 (production 200 000) | |
limits.max_rows |
200 | upper bound for LIMIT n and for returned rows |
limits.max_cell_chars |
200 | longer cells are cut with … |
limits.max_output_bytes |
65 536 | output is cut with a marker |
limits.k_anonymity |
5 (production 10) | a PII filter, a group or an aggregate of a PII column covering fewer rows warns on the approval screen (it no longer refuses); lowering it is a loosening |
limits.reference_probe |
5 | no effect: cell references are not supported; kept so that existing configs load (raising it is still recorded as a loosening) |
limits.idle_timeout |
20m (production 10m), or max_session when that is shorter |
the database connection and the SSH tunnel close after this long without activity; the console keeps running, and the next request asks a connect ticket in lox run (usage); at least 1m and at most max_session; raising it is a loosening |
limits.max_session |
4h (production 2h) | the console session ends after this long in all; at most 24h; raising it is a loosening |
limits.tickets_per_window, limits.ticket_window |
30 per 5m |
tickets a human decides that the console opens per window at most; a ticket an auto rule of lox run decides (read = auto) leaves this count, and a safety cap of 300 tickets per window counts every one (Ticket limits); more tickets or a shorter window is a loosening |
limits.max_pending |
30 | tickets pending a decision at once at most; raising it is a loosening |
limits.explain_cost_refuse |
0 (off) | refuse plans above this total cost, in the engine's own units; SQLite reports no cost and is not checked |
ssh.host, ssh.port, ssh.user |
required host and user; port 22 | the bastion; not with sqlite or a Unix socket host |
ssh.auth |
required | key, agent (SSH_AUTH_SOCK of the console) or password |
ssh.key |
required with auth = "key" |
private key of the console account, mode 0600 |
tls_ca, tls_cert, tls_key |
none | PEM files read by the console account (TLS to the database) |
ssh.credentials |
the profile's credentials (ask when that is vault) |
ask or keychain for the passphrase or SSH password; locksql forget --profile P removes it too |
proxy_command |
none | argv run without a shell, as the console account, once per connection; its stdin and stdout carry it (docker exec, kubectl exec, ssh ... socat). Not with ssh, sqlite or a Unix socket host; any change is a loosening |
In every file key (tls_ca, tls_cert, tls_key, ssh.key), a leading
~/ is the console account's home (/home/locksql in the image); the
console checks each file right after the policy review, before any secret
is asked.
PII rules #
PII column rules live in named rule sets, one file per set: pii/<set>.toml
next to the config, so .locksql/pii/<set>.toml inside a project and
<user config dir>/locksql/pii/<set>.toml (~/.config/locksql/pii/ on
Linux) elsewhere. Each profile uses the set named by its pii_rules key,
by default its own name, so a profile never sees the rules of an unrelated
database. Profiles that point at isomorphic databases share a set on
purpose:
[profiles.shop-dev]
pii_rules = "shop"
[profiles.shop-prod]
pii_rules = "shop" # rules added from either console reach both
production = true
[profiles.billing] # no pii_rules: the set "billing", its own
locksql pii and :pii act on the set of the profile in use, and the
approval screen shows the set next to the profile. The console warns at start
when a profile without pii_rules shares its set by accident: with a
profile whose name differs only by case, with a profile whose pii_rules
names it, or because it is named default. pii_rules is read at
start; pointing a profile at another set is a loosening, approved again at
the next start.
The single pii.toml of earlier versions becomes the set default at the
first start (moved to pii/default.toml, with a one-time notice). The
profiles approved before keep using it until they get a pii_rules key;
any other profile uses its own set.
A set file:
[[mask]]
column = "app.users.email" # db.table.column (schema.table.column on PostgreSQL), * per segment
mode = "email" # j***@example.com
[[mask]]
column = "app.users.customer_ref"
mode = "partial" # j***(12)
[[mask]]
column = "*.*.recipient_reference" # <redacted> (default)
[[allow]] # explicit exception: never mask
column = "app.templates.name"
[[mask]] # '*' prefix/suffix inside a segment:
column = "*.*.*email*" # contact_email, email_backup, Email
| Mode | Output | Notes |
|---|---|---|
redact (default) |
<redacted> |
a rule without mode is redact; no masked cell carries a reference to its value |
partial |
j***(12) |
first character and length |
email |
j***@example.com |
first character and domain; other values as partial |
A column covered by several rules with different modes is redacted. Changing
a mode is a loosening unless the new mode is redact.
mode = "hash" is no longer a mode: a rules file that uses it is rejected
([[mask]] entry N: pii rule: unknown mode (want redact, partial or email)).
On PostgreSQL the first segment is the schema (public.users.email).
Share a configuration with your team #
locksql config export writes the profiles of your user config (or of the
project with --project) and the PII rule sets they use to one JSON file;
locksql config import FILE applies it on another machine:
locksql config export --output team.json # all profiles, their PII rule sets
locksql config import team.json --dry-run # show what would change
locksql config import team.json --local uat.ssh.key=~/.ssh/id_ed25519
- Never exported: secrets (none is in a config file; a value that looks
like one fails the export),
user(unless--include-user), files of one machine (tls_ca,tls_cert,tls_key,ssh.key: listed inneeds_local, given on import with--local NAME.KEY=VALUE),proxy_command(a command line: kept from your own config, never imported), SQLite and Unix-socket profiles, approved policies, the audit log and console flags.--no-piileaves the rule sets out. - Import: a strict format (unknown fields, newer formats and invalid
values are refused, nothing is written); imported profiles replace those
of the same name but keep your
userand local files; rule sets are merged (a mode conflict keepsredact);--replacereplaces the profiles and the imported sets instead. A backup (*.bak) is kept. - Loosening: any change that loosens the policy (new profile or host,
higher tier, higher limits, a removed mask rule, a new allow
rule, another rule set, ...) is refused unless you pass
--allow-loosen, and the console still asks you to approve it at its next start.
The file describes your databases: keep it in a private repository.
Managed configuration #
A company can bound every locksql of a machine with one file its admin
installs: /etc/lockfamily/managed.toml (Linux) or /Library/Application Support/lockfamily/managed.toml (macOS), owned by root and writable by root
only, optionally signed (the family's format, signing and deployment:
lockfamily docs/managed.md).
A user may tighten what it allows, never loosen it. locksql reads its
[locksql] section:
version = 1
organisation = "Acme"
[locksql.bounds]
tier = "write"
tls = "verify-full"
credentials = ["vault", "ask"]
hosts = ["*.db.acme.internal"]
detectors = ["fr_nir"]
auto = { ddl = "deny" }
limits = { max_rows = 500, max_session = "2h" }
[[locksql.bounds.pii_mask]]
column = "crm.customers.email"
mode = "email"
[locksql.defaults]
limits = { max_rows = 100, max_session = "2h" }
[locksql.profiles.prod-erp]
engine = "postgres"
host = "erp.db.acme.internal"
database = "erp"
production = true
credentials = "vault"
vault_path = "database/creds/erp-ro"
The precedence is: the hard-coded floor > the managed bounds and profiles >
the project file > the user file > the managed defaults > the built-in
defaults. The merged result is checked against the bounds at the console's
start and at every policy poll (every 2 s): a profile beyond them is refused
with no prompt (no human can approve past it), one line each, audited
refused-managed:
blocked by company policy: locksql.bounds.tier = write (profile dev: tier = ddl)
A managed file that cannot be read (badly owned, invalid, badly signed) is a
refusal to start, never ignored. Once the console serves, a stricter file is
applied at the next poll: the policy in force is replaced by the files'
policy when that one is within the new bounds, else the session ends. The
start screen shows Managed by <organisation> (sha256:…), the approved
policy records managed_sha256 and every audit record managed.
Bounds #
[locksql.bounds] takes these keys; each is optional. "Looser" is what the
bound refuses.
| Key | Type | Looser |
|---|---|---|
production |
true |
production = false |
tier |
read, write, ddl or admin (a ceiling) |
a higher tier |
tls |
a TLS mode (a floor) | a weaker mode (disable < prefer < require < verify-ca < verify-full); SQLite has none |
credentials |
allowed modes (["vault", "ask"]) |
a mode outside the list |
engine |
allowed engines (["postgres"]) |
an engine outside the list |
hosts |
host patterns (*.db.acme.internal, path.Match syntax, case-insensitive) on host and ssh.host |
a host no pattern matches |
write_functions |
"allowlist" |
the denylist |
credentials_ttl |
a duration (a ceiling) | longer, or none (the secret kept for the session) |
auto.connect, auto.read, auto.write, auto.ddl, auto.unmask |
deny, ask or auto (a ceiling) |
toward auto |
limits.statement_timeout, limits.max_rows, limits.max_cell_chars, limits.max_output_bytes, limits.explain_rows_refuse |
a ceiling | higher, or 0 (unlimited) |
limits.k_anonymity |
a floor | lower |
limits.idle_timeout, limits.max_session |
a duration (a ceiling) | longer |
limits.grant_max_duration |
a duration, or 0 (grants off) |
longer, or grants on under 0 |
limits.grant_max_statements, limits.grant_max_rows, limits.grant_max_bytes |
a ceiling | higher |
detectors |
detectors every profile runs | added to every profile, never removed |
[[pii_mask]] |
column = "db.table.column", mode = "redact", "partial" or "email" (as [[mask]] in a rule set) |
masks added to every profile's rules, whatever its pii_rules |
credentials,engineandhostsare allowlists: a value outside one is refused naming the key (blocked by company policy: locksql.bounds.hosts does not allow db.other.com (profile dev: host + db.other.com)).[family] vault = { required = true }acts ascredentials = ["vault"]on every network profile, cited asfamily.vault.required.- A managed mask takes its mode over a rule of the same column in the rule
set. A user
[[allow]]that could clear a managed mask (*.*.emailovercrm.customers.email) is refused at load; a user may add mask rules, never remove or weaken a managed one. - A bound on a duration or a size is never clamped silently: a profile beyond it is refused, and you lower it in the config file.
Defaults and profiles #
[locksql.defaults]takes the keys of a profile,limits.*included. A key a profile leaves unset takes it, before the built-in defaults; a value of the config file wins (within the bounds). Thelimitstable is merged key by key,sshandautoare taken whole. A default beyond the bounds is a load error.[locksql.profiles.<name>]has exactly the keys of[profiles.<name>], is served as written (with the defaults), and wins as a whole: a project or user profile of its name is refused (profile "prod-erp" is provided by the company's managed configuration and cannot be redefined in the project config: rename yours). A managed profile beyond the bounds is a load error.
Commands #
locksql managed show: the file's path, organisation, fingerprint and signature, then the active bounds, defaults and managed profile names.no managed configuration(exit 0) when there is none; a refused file prints the error (exit 3).locksql managed check [--profile NAME]: the profiles of the config files against the bounds, as the console checks them at start, without a connection or a credential: oneblocked by company policy: …line per violation (exit 3), elseok: N profiles within the company policy (…)(exit 0).loxruns it before it starts the lock.locksql doctorhas amanagedline: the file as above,no managed configuration, or the error that refuses it.