feat: give each Kanban operator their own account
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m17s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m17s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m37s
One OPERATOR_EMAIL and OPERATOR_PASSWORD served the whole factory, so every card movement recorded the same name and the movement history could not answer who did what. Traceability was one of the things the project set out to provide. Accounts live in dtf_local.operators, authenticated with the same scrypt hashing as customer accounts and with comparable work whether or not the account exists, so absence is not observable by timing. Administration is a CLI in the API container, like the schema migration: list, add, password, disable, enable. Passwords are read from the terminal rather than an argument so they stay out of shell history and the process list, and disabling deletes that operator's open sessions instead of leaving them valid for the rest of the eight-hour window. Migration is the part that could hurt: an empty table means 503 and a factory locked out of its Kanban. OPERATOR_EMAIL and OPERATOR_PASSWORD seed the first account, and only when that email is absent, so a password changed through the CLI survives a redeploy carrying a stale environment variable. The first attempt at this silently did nothing, because db-init receives its own small environment and had neither variable; both compose files now pass them to it. Verified against a running stack: bootstrap seeds the existing credential, that credential still logs in unchanged, a second operator authenticates separately, wrong passwords and unknown accounts are rejected alike, and disabling revokes an open session immediately. Roles are left out on purpose. The separation of duties the meeting described governs rework authorisation, which this system does not implement, so a role model would have no consumer to serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
28
ROADMAP.md
28
ROADMAP.md
@@ -274,12 +274,30 @@ workaround — but staying on it indefinitely is not a posture. Upgrading is an
|
|||||||
change rather than a file swap and needs its own browser testing, so it is
|
change rather than a file swap and needs its own browser testing, so it is
|
||||||
deliberately not bundled here.
|
deliberately not bundled here.
|
||||||
|
|
||||||
### `[ ]` 2.8 — Single shared operator credential `(F12)`
|
### `[x]` 2.8 — Single shared operator credential `(F12)`
|
||||||
|
|
||||||
One `OPERATOR_EMAIL`/`OPERATOR_PASSWORD` for the whole factory; `movements.operator`
|
Accounts now live in `dtf_local.operators`, one per person, with `movements.operator`
|
||||||
records the same name for everyone. The meeting asked for traceability, and the old
|
and `order_files.created_by` recording who actually acted. Administered from the API
|
||||||
`kanban/main.py` explicitly designed separation of duties (Mayana classifies,
|
container with `python -m local.operators` (list, add, password, disable, enable);
|
||||||
Thales/Alexandre authorise). Needs real per-person accounts with roles.
|
passwords are read from the terminal so they never reach shell history or the
|
||||||
|
process list, and disabling revokes open sessions immediately rather than leaving
|
||||||
|
them valid for the rest of the eight-hour window.
|
||||||
|
|
||||||
|
Migration was the risk, since getting it wrong locks the factory out of the Kanban.
|
||||||
|
`OPERATOR_EMAIL`/`OPERATOR_PASSWORD` seed the first account, once: a password
|
||||||
|
changed through the CLI is never reverted by a stale environment variable on the
|
||||||
|
next deploy. The first attempt did not work — `db-init` was not given those
|
||||||
|
variables in either compose file, so no account would have been created and login
|
||||||
|
would have failed closed with 503. Both files now pass them to the migration job.
|
||||||
|
Verified end to end: the unchanged credential still logs in, a second operator
|
||||||
|
authenticates separately, wrong passwords and unknown accounts are rejected, and
|
||||||
|
disabling ends access at once.
|
||||||
|
|
||||||
|
**Roles are deliberately not included.** The meeting described separation of duties
|
||||||
|
for rework authorisation (Mayana classifies, Thales or Alexandre authorise), but the
|
||||||
|
rework feature does not exist in this system, so there is nothing for a role to
|
||||||
|
gate. Building an authorisation model with no consumer would be guesswork. Add roles
|
||||||
|
with the feature that needs them.
|
||||||
|
|
||||||
### `[~]` 2.9 — TLS is terminated outside the repository `(F13)`
|
### `[~]` 2.9 — TLS is terminated outside the repository `(F13)`
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ services:
|
|||||||
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
|
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
|
||||||
APP_DB_USER: ${APP_DB_USER:-dtf_app}
|
APP_DB_USER: ${APP_DB_USER:-dtf_app}
|
||||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only}
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only}
|
||||||
|
# The migration job seeds the first operator account from these, so an
|
||||||
|
# existing deployment keeps its Kanban login after the accounts table
|
||||||
|
# lands. Without them there would be no account at all and login would
|
||||||
|
# fail closed with 503.
|
||||||
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
|
||||||
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
|
||||||
networks: [local]
|
networks: [local]
|
||||||
depends_on:
|
depends_on:
|
||||||
db: {condition: service_healthy}
|
db: {condition: service_healthy}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ services:
|
|||||||
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
APP_DB_USER: dtf_app
|
APP_DB_USER: dtf_app
|
||||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
|
||||||
|
# The migration job seeds the first operator account from these, so an
|
||||||
|
# existing deployment keeps its Kanban login after the accounts table
|
||||||
|
# lands. Without them there would be no account at all and login would
|
||||||
|
# fail closed with 503.
|
||||||
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||||
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||||
networks: [backend]
|
networks: [backend]
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
|||||||
19
local/app.py
19
local/app.py
@@ -18,7 +18,7 @@ from .secrets import load as load_secret_files
|
|||||||
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
|
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
|
||||||
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
|
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
|
||||||
from .pricing import price
|
from .pricing import price
|
||||||
from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit
|
from .auth import COOKIE_SECURE, DUMMY_PASSWORD_HASH, client_ip, owner, session_row, new_session, operator, password_matches, throttle, audit, rate_limit
|
||||||
from .scanning import require_clean
|
from .scanning import require_clean
|
||||||
|
|
||||||
load_secret_files()
|
load_secret_files()
|
||||||
@@ -54,15 +54,17 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
|||||||
|
|
||||||
@app.post('/api/operator/login')
|
@app.post('/api/operator/login')
|
||||||
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||||
configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
|
|
||||||
if not configured_email:
|
|
||||||
raise HTTPException(503, 'Kanban operator email is not configured')
|
|
||||||
email = body.email
|
email = body.email
|
||||||
throttle('operator:'+email, request)
|
throttle('operator:'+email, request)
|
||||||
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
|
with db.connect() as c:
|
||||||
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
|
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
|
||||||
if not (valid_user and valid_password):
|
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
|
||||||
audit('operator_login_failed', ip=client_ip(request))
|
raise HTTPException(503, 'No Kanban operator account is configured')
|
||||||
|
# Comparable password work whether or not the account exists or is active.
|
||||||
|
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||||
|
matches = password_matches(body.password, stored)
|
||||||
|
if not account or not account['active'] or not matches:
|
||||||
|
audit('operator_login_failed', ip=client_ip(request), operator=email)
|
||||||
raise HTTPException(401, 'Invalid operator login')
|
raise HTTPException(401, 'Invalid operator login')
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
with db.connect() as c:
|
with db.connect() as c:
|
||||||
@@ -70,6 +72,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
|
|||||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
||||||
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
||||||
(hashlib.sha256(token.encode()).hexdigest(), email))
|
(hashlib.sha256(token.encode()).hexdigest(), email))
|
||||||
|
c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
|
||||||
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
||||||
samesite='strict', path='/api/operator', max_age=28800)
|
samesite='strict', path='/api/operator', max_age=28800)
|
||||||
audit('operator_login_success', operator=email)
|
audit('operator_login_success', operator=email)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from urllib.parse import urlparse
|
|||||||
import psycopg
|
import psycopg
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from .secrets import load as load_secret_files
|
from .secrets import load as load_secret_files
|
||||||
|
from .operators import seed_from_environment
|
||||||
|
|
||||||
|
|
||||||
def admin_connect():
|
def admin_connect():
|
||||||
@@ -45,6 +46,13 @@ def main():
|
|||||||
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
||||||
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
||||||
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
||||||
|
# Turn the configured credential into a real account so an existing
|
||||||
|
# deployment keeps logging in exactly as before. Inserts only when that
|
||||||
|
# email is absent, so a password changed with local.operators is never
|
||||||
|
# reverted by a stale environment variable on the next deploy.
|
||||||
|
seeded = seed_from_environment(c)
|
||||||
print('Local schema migrated; runtime role has DML only.')
|
print('Local schema migrated; runtime role has DML only.')
|
||||||
|
if seeded:
|
||||||
|
print(f'Seeded operator account {seeded} from OPERATOR_EMAIL.')
|
||||||
|
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|||||||
130
local/operators.py
Normal file
130
local/operators.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""Kanban operator accounts.
|
||||||
|
|
||||||
|
One shared login meant every card movement was attributed to the same name, so
|
||||||
|
the movement history could not answer who did what. Accounts live in the
|
||||||
|
database; `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` seed the first one so an
|
||||||
|
existing deployment keeps working unchanged.
|
||||||
|
|
||||||
|
Administered from the API container, the same way the schema is:
|
||||||
|
|
||||||
|
python -m local.operators list
|
||||||
|
python -m local.operators add maria@example.com --name "Maria"
|
||||||
|
python -m local.operators password maria@example.com
|
||||||
|
python -m local.operators disable maria@example.com
|
||||||
|
python -m local.operators enable maria@example.com
|
||||||
|
|
||||||
|
Passwords are read from the terminal, never from an argument, so they do not
|
||||||
|
reach shell history or the process list.
|
||||||
|
"""
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .auth import password_hash
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
MIN_PASSWORD = 12
|
||||||
|
|
||||||
|
|
||||||
|
def seed_from_environment(cursor):
|
||||||
|
"""Make the configured credential a real account, once.
|
||||||
|
|
||||||
|
Only inserts when that email is absent, so a password changed here is never
|
||||||
|
reverted by a stale environment variable on the next deploy.
|
||||||
|
"""
|
||||||
|
email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
|
||||||
|
password = os.environ.get('OPERATOR_PASSWORD', '')
|
||||||
|
if not email or not password:
|
||||||
|
return None
|
||||||
|
existing = cursor.execute(
|
||||||
|
'SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return None
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
|
||||||
|
(uuid4(), email, 'Operador', password_hash(password)))
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_password(email):
|
||||||
|
first = getpass.getpass(f'New password for {email}: ')
|
||||||
|
if len(first) < MIN_PASSWORD:
|
||||||
|
raise SystemExit(f'Password must be at least {MIN_PASSWORD} characters.')
|
||||||
|
if first != getpass.getpass('Repeat: '):
|
||||||
|
raise SystemExit('Passwords did not match.')
|
||||||
|
return first
|
||||||
|
|
||||||
|
|
||||||
|
def add(email, name=''):
|
||||||
|
email = email.strip().lower()
|
||||||
|
with connect() as c:
|
||||||
|
if c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
|
||||||
|
raise SystemExit(f'{email} already exists. Use "password" or "enable".')
|
||||||
|
c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
|
||||||
|
(uuid4(), email, name, password_hash(_ask_password(email))))
|
||||||
|
print(f'Added {email}.')
|
||||||
|
|
||||||
|
|
||||||
|
def password(email):
|
||||||
|
email = email.strip().lower()
|
||||||
|
with connect() as c:
|
||||||
|
if not c.execute('SELECT id FROM dtf_local.operators WHERE email=%s', (email,)).fetchone():
|
||||||
|
raise SystemExit(f'{email} does not exist.')
|
||||||
|
c.execute('UPDATE dtf_local.operators SET password_hash=%s WHERE email=%s',
|
||||||
|
(password_hash(_ask_password(email)), email))
|
||||||
|
print(f'Password changed for {email}. Existing sessions stay valid until they expire;'
|
||||||
|
' run "disable" first if the account is compromised.')
|
||||||
|
|
||||||
|
|
||||||
|
def set_active(email, active):
|
||||||
|
email = email.strip().lower()
|
||||||
|
with connect() as c:
|
||||||
|
updated = c.execute(
|
||||||
|
'UPDATE dtf_local.operators SET active=%s WHERE email=%s RETURNING email',
|
||||||
|
(active, email)).fetchone()
|
||||||
|
if not updated:
|
||||||
|
raise SystemExit(f'{email} does not exist.')
|
||||||
|
if not active:
|
||||||
|
# Revoke immediately: disabling must end access now, not in eight hours.
|
||||||
|
c.execute('DELETE FROM dtf_local.operator_sessions WHERE username=%s', (email,))
|
||||||
|
print(f'{email} {"enabled" if active else "disabled; open sessions revoked"}.')
|
||||||
|
|
||||||
|
|
||||||
|
def listing():
|
||||||
|
with connect() as c:
|
||||||
|
rows = c.execute('''SELECT email,name,active,last_login_at FROM dtf_local.operators
|
||||||
|
ORDER BY email''').fetchall()
|
||||||
|
if not rows:
|
||||||
|
print('No operator accounts. Set OPERATOR_EMAIL and OPERATOR_PASSWORD and run'
|
||||||
|
' "python -m local.bootstrap", or add one here.')
|
||||||
|
return
|
||||||
|
for row in rows:
|
||||||
|
seen = row['last_login_at'].strftime('%Y-%m-%d %H:%M') if row['last_login_at'] else 'never'
|
||||||
|
state = 'active ' if row['active'] else 'DISABLED'
|
||||||
|
print(f"{state} {row['email']:<34} {row['name'][:20]:<20} last login {seen}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if not argv:
|
||||||
|
raise SystemExit(__doc__)
|
||||||
|
command, rest = argv[0], argv[1:]
|
||||||
|
if command == 'list':
|
||||||
|
return listing()
|
||||||
|
if not rest:
|
||||||
|
raise SystemExit(f'usage: python -m local.operators {command} <email>')
|
||||||
|
email = rest[0]
|
||||||
|
if command == 'add':
|
||||||
|
name = rest[rest.index('--name') + 1] if '--name' in rest else ''
|
||||||
|
return add(email, name)
|
||||||
|
if command == 'password':
|
||||||
|
return password(email)
|
||||||
|
if command == 'disable':
|
||||||
|
return set_active(email, False)
|
||||||
|
if command == 'enable':
|
||||||
|
return set_active(email, True)
|
||||||
|
raise SystemExit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv[1:])
|
||||||
@@ -26,8 +26,34 @@ def check_client_ip():
|
|||||||
assert client_ip(FakeRequest(peer=None))=='unknown'
|
assert client_ip(FakeRequest(peer=None))=='unknown'
|
||||||
print('PASS: client address resolution for rate-limit buckets and audit events')
|
print('PASS: client address resolution for rate-limit buckets and audit events')
|
||||||
|
|
||||||
|
def check_operator_accounts():
|
||||||
|
"""Accounts are per person, and disabling one ends its access at once."""
|
||||||
|
from uuid import uuid4
|
||||||
|
from .auth import password_hash, password_matches
|
||||||
|
from .operators import seed_from_environment, set_active
|
||||||
|
email='runtime-check-'+uuid4().hex[:8]+'@example.test'
|
||||||
|
with connect() as c:
|
||||||
|
c.execute('INSERT INTO dtf_local.operators(id,email,name,password_hash) VALUES(%s,%s,%s,%s)',
|
||||||
|
(uuid4(), email, 'Runtime Check', password_hash('runtime-check-password')))
|
||||||
|
row=c.execute('SELECT * FROM dtf_local.operators WHERE email=%s',(email,)).fetchone()
|
||||||
|
assert row['active'] and password_matches('runtime-check-password', row['password_hash'])
|
||||||
|
assert not password_matches('wrong', row['password_hash'])
|
||||||
|
# Seeding is once-only: a password changed here must survive a redeploy.
|
||||||
|
c.execute("INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)",
|
||||||
|
('runtime-check-'+uuid4().hex, email))
|
||||||
|
set_active(email, False)
|
||||||
|
with connect() as c:
|
||||||
|
row=c.execute('SELECT active FROM dtf_local.operators WHERE email=%s',(email,)).fetchone()
|
||||||
|
sessions=c.execute('SELECT count(*) AS n FROM dtf_local.operator_sessions WHERE username=%s',
|
||||||
|
(email,)).fetchone()['n']
|
||||||
|
assert not row['active'], 'disable did not deactivate'
|
||||||
|
assert sessions==0, 'disable left an open session behind'
|
||||||
|
c.execute('DELETE FROM dtf_local.operators WHERE email=%s',(email,))
|
||||||
|
print('PASS: per-operator accounts, password verification, immediate revocation on disable')
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
check_client_ip()
|
check_client_ip()
|
||||||
|
check_operator_accounts()
|
||||||
with connect() as c:
|
with connect() as c:
|
||||||
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
|
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
|
||||||
assert not any(role.values()),role
|
assert not any(role.values()),role
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
|
|||||||
token_hash text PRIMARY KEY, username text NOT NULL,
|
token_hash text PRIMARY KEY, username text NOT NULL,
|
||||||
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
|
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS dtf_local.operators (
|
||||||
|
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, name text NOT NULL DEFAULT '',
|
||||||
|
password_hash text NOT NULL, active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
|
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
|
||||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
|||||||
Reference in New Issue
Block a user