foren.dev

Database Schema

This document covers all license-related tables in the unified MySQL database.

The schema name is deployment-specific and is not hard-coded in the application. It is read from the FOREN_DB_NAME environment variable (see config.php and license/config.php; both fall back to the placeholder foren). Below, <DB> and <DB_USER> stand for whatever those variables are set to. See DEPLOYMENT.md for how to confirm the live values.

Entity-Relationship Diagram (ASCII)

                ┌─────────────┐
                │  admins     │
                └─────────────┘

  ┌──────────────────┐
  │  licenses        │◄─────┐
  │──────────────────│      │
  │ id (PK)          │      │
  │ license_key (U)  │      │
  │ product          │      │
  │ plan (FK→plans)  │──────┼──►┌───────────────┐
  │ holder           │      │   │  license_      │
  │ ...              │      │   │  plans        │
  └──────────────────┘      │   │──────────────│
         │                        │     slug (PK)  │
         │ 1:N                    │     features  │
         ▼                        └───────────────┘
  ┌──────────────────┐
  │  license_devices │      ┌─────────────────────────┐
  │──────────────────│      │ license_product_        │
  │ id (PK)          │      │ authorizations          │
  │ license_id (FK)  │      │─────────────────────────│
  │ machine_code     │      │ license_id (FK)         │
  │ machine_fingerprint│    │ product_slug            │
  │ is_revoked       │      │ is_active               │
  └──────────────────┘      │ expires_at              │
                            └─────────────────────────┘
                                     │
                                     ▼
                            ┌─────────────────────────┐
                            │ license_authorization_  │
                            │ tokens                  │
                            │─────────────────────────│
                            │ token_hash (U)          │
                            │ license_id (FK)         │
                            │ product_slug            │
                            │ expires_at              │
                            └─────────────────────────┘

  ┌──────────────────┐  ┌─────────────────┐  ┌────────────────┐
  │ activation_logs  │  │ certificates    │  │ license_events │
  │──────────────────│  │─────────────────│  │────────────────│
  │ id (PK)          │  │ id (PK)         │  │ id (PK)        │
  │ license_id (FK)  │  │ serial (U)      │  │ license_id(FK) │
  │ activation_code  │  │ cert_pem        │  │ event_type     │
  │ machine_         │  │ key_pem_        │  │ delivered     │
  │   fingerprint    │  │   encrypted     │  │ retry_count    │
  │ result           │  │ status          │  │ last_error    │
  │ ...              │  │ key_download_   │  └────────────────┘
  └──────────────────┘  │   token         │         │
                       │ expires_at      │         ▼
                       └─────────────────┘  ┌────────────────┐
                                            │ license_       │
                                            │ webhooks       │
                                            │────────────────│
                                            │ url            │
                                            │ secret        │
                                            │ scope         │
                                            └────────────────┘

  ┌──────────────────┐  ┌─────────────────┐
  │ api_tokens       │  │ products        │
  │──────────────────│  │─────────────────│
  │ id (PK)          │  │ id (PK)         │
  │ token_hash (U)   │  │ slug (U)        │
  │ name             │  │ name            │
  │ scope            │  │ is_active       │
  │ expires_at       │  └─────────────────┘
  └──────────────────┘

Tables

licenses — Core license records

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
license_keyVARCHARUNIQUE. Format: COF-YYYYMMDD-XXXXXX
batch_noVARCHARGroup identifier for batch-generated licenses
productVARCHARe.g., CoForen
product_versionVARCHARe.g., 0.8.1
holderVARCHARCustomer name
issued_toVARCHAROptional issued-to name
planVARCHARReferences license_plans.slug (logical FK)
features_jsonJSONActive feature list
max_membersINTPlan limit
max_casesINTPlan limit
issued_atDATETIME
expires_atDATETIMENULL = never expires
offline_allowedTINYINT(1)Allow offline license generation
grace_daysINTOverride plan default grace days
is_activeTINYINT(1)1=active
notesTEXTAdmin notes
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, UNIQUE(license_key), idx(expires_at), idx(is_active, expires_at)

license_devices — Activated devices

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
license_idBIGINT UNSIGNED FK→licenses(id)CASCADE DELETE
activation_code_idBIGINT UNSIGNEDFK to activation_codes
machine_codeVARCHAR1-128 chars [A-Za-z0-9_\-.]+
machine_fingerprintVARCHARFormat sha256:[a-f0-9]{64}
device_nameVARCHAR
osVARCHAR
client_versionVARCHAR
first_activated_atDATETIME
last_seen_atDATETIMEUpdated on each activation
is_revokedTINYINT(1)1=revoked (device blocked)
notesTEXT
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, UNIQUE(license_id, machine_fingerprint) (uniq_license_machine), idx(license_id, is_revoked), idx(machine_fingerprint)

activation_logs — Activation audit trail

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
license_idBIGINT UNSIGNED FK→licenses(id)
activation_codeVARCHARThe code presented
machine_fingerprintVARCHAR
machine_codeVARCHAR
ipVARCHARClient IP
user_agentVARCHARClient User-Agent
resultENUM'success', 'failed', 'error'
messageTEXTReason / status
license_formatVARCHAR'v1' or 'v2'
request_jsonJSONOriginal request (debug)
created_atDATETIME

Indexes: PRIMARY, idx(license_id, created_at), idx(created_at)

license_product_authorizations — Per-product feature gates

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
license_idBIGINT UNSIGNED FK→licenses(id)CASCADE DELETE
product_slugVARCHAR(64)
authorized_atDATETIME
expires_atDATETIMENULL = perpetual
is_activeTINYINT(1)
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, UNIQUE(license_id, product_slug), idx(license_id, product_slug)

license_authorization_tokens — Per-product token grants

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
token_hashCHAR(64) UNIQUESHA-256 hex of plaintext token
license_idBIGINT UNSIGNED FK→licenses(id)CASCADE DELETE
product_slugVARCHAR(64)
nameVARCHAR(128)Label
is_activeTINYINT(1)
expires_atDATETIME
last_used_atDATETIME
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, UNIQUE(token_hash), idx(license_id), idx(active)

license_plans — Plan definitions

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
slugVARCHAR(64) UNIQUEe.g., 'team', 'enterprise'
nameVARCHAR(128)Display name
descriptionTEXT
features_jsonJSONFeature list
max_membersINT
max_casesINT
grace_daysINTDays after expiry during which license still validates
is_activeTINYINT(1)
sort_orderINTDisplay order
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, UNIQUE(slug)

certificates — X.509 client certificates

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
serialVARCHAR(64) UNIQUEX.509 serial in hex
cert_pemTEXTPublic certificate
key_pem_encryptedTEXTAES-256-CBC encrypted private key
owner_license_idBIGINT UNSIGNED FK→licenses(id)NULL = orphan
owner_nameVARCHAR(255)CN in cert
statusENUM'active', 'revoked', 'expired'
created_atDATETIME
expires_atDATETIMEX.509 notAfter
revoked_atDATETIME
created_by_admin_idBIGINT UNSIGNED FK→admins(id)
key_download_tokenVARCHAR(64)Single-use, NULL after consumption
key_download_expires_atDATETIME30 min from issuance

Indexes: PRIMARY, UNIQUE(serial), UNIQUE(key_download_token), idx(owner_license_id), idx(status), idx(expires_at)

license_events — Outbound event log

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
license_idBIGINT UNSIGNED FK→licenses(id)
event_typeVARCHAR(64)e.g., 'license.activated'
payload_jsonJSONEvent data
deliveredTINYINT(1)1 = delivered to at least one webhook
delivered_atDATETIME
retry_countINTRetry attempts (max 5)
last_errorTEXTLast delivery error
created_atDATETIME

Indexes: PRIMARY, idx(license_id), idx(event_type), idx(created_at), idx(delivered, retry_count)

license_webhooks — Webhook subscriptions

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
urlVARCHAR(512)Consumer URL
secretVARCHAR(128)HMAC shared secret (shown once)
scopeVARCHAR(32)'all' or specific event type
is_activeTINYINT(1)
created_by_admin_idBIGINT UNSIGNED
created_atDATETIME
updated_atDATETIME

Indexes: PRIMARY, idx(is_active)

api_tokens — API authentication tokens

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
nameVARCHARLabel
token_hashVARCHAR UNIQUESHA-256 of plaintext
scopeVARCHAR'license' for license module
expires_atDATETIMENULL = no expiry
last_used_atDATETIME
is_activeTINYINT(1)
created_atDATETIME
updated_atDATETIME

products — Product catalog

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
slugVARCHAR UNIQUEe.g., 'CoForen'
nameVARCHAR
default_versionVARCHAR
default_features_jsonJSON
default_grace_daysINT
is_activeTINYINT(1)
notesTEXT
created_atDATETIME
updated_atDATETIME

admins — Admin users

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
usernameVARCHAR UNIQUE
password_hashVARCHARbcrypt
is_activeTINYINT(1)
last_login_atDATETIME
created_atDATETIME
updated_atDATETIME
totp_secretVARCHAR(255)AES-256-GCM ciphertext of the TOTP secret; NULL = 2FA disabled (migration 012)
failed_loginsINT UNSIGNEDConsecutive failed password attempts (migration 012)
locked_untilDATETIMEAccount locked until this UTC time; NULL = not locked (migration 012)
failed_login_atDATETIMETimestamp of the last failed attempt (migration 012)

admin_audit_logs — Admin action audit

ColumnTypeNotes
---------
idBIGINT UNSIGNED PK
admin_idBIGINT UNSIGNED
actionVARCHAR(64)e.g., 'login', 'revoke', 'create', 'totp_enable', 'use_backup_code'
resource_typeVARCHAR(64)e.g., 'certificate', 'license'
resource_idVARCHAR
detail_jsonJSONAdditional context
ipVARCHAR
created_atDATETIME

admin_sessions — Admin session registry (migration 012)

Backs the session list and revocation UI on System → Security.

ColumnTypeNotes
---------
idVARCHAR(64) PKSHA-256 hex of the PHP session id; the raw id is never stored
admin_idINT UNSIGNED FKOwning administrator (ON DELETE CASCADE)
ipVARCHAR(45)Client IP at login (fits IPv6)
user_agentVARCHAR(512)Browser / client fingerprint
created_atDATETIMELogin time
last_active_atDATETIMELast observed request
revoked_atDATETIMENULL while active; set on revocation (kept for audit)

Indexes: idx_admin_id (admin_id), idx_revoked (revoked_at).

admin_2fa_backup_codes — Single-use 2FA backup codes (migration 012)

ColumnTypeNotes
---------
idINT UNSIGNED PK
admin_idINT UNSIGNED FKOwning administrator (ON DELETE CASCADE)
code_hashCHAR(64)SHA-256 hex of the normalised code (XXXX-XXXX-XXXX-XXXX, upper-cased, separators stripped)
created_atDATETIMEIssuance time
used_atDATETIMENULL = unused; set on first redemption
used_ipVARCHAR(45)IP that redeemed the code

Only the hash is stored, so a database leak does not yield usable codes. Redemption is a conditional UPDATE ... WHERE used_at IS NULL, so concurrent requests cannot both succeed with the same code.

Index: idx_admin_unused (admin_id, used_at).

Migrations

FilePurpose
------
001_mysql.sqlInitial MySQL schema (licenses, devices, activations)
002_signature_compat.sqlAdds machine_code column for v2 signature
003_batch_v2_admin.sqlAdds batch_no for batch operations
004_products.sqlProduct registry (legacy path; superseded by 005)
005_unified_catalog.sqlAdds product catalog tables
006_license_product_authorizations.sqlAdds license_product_authorizations
007_certificates.sqlAdds certificates table
008_license_plans.sqlAdds license_plans table
009_license_events_webhooks.sqlAdds license_events + license_webhooks
010_license_authorization_tokens.sqlAdds license_authorization_tokens
011_security_and_performance.sqlAdds certificates.key_download_token/expires_at columns and performance indexes (idempotent)
012_admin_security.sqlAdds admins 2FA/lockout columns, admin_sessions, admin_2fa_backup_codes

Running migrations

Migrations are SQL files in license/migrations/. Run them in numeric order (skipping legacy/, which holds the superseded 004_products.sql):

cd /www/wwwroot/foren.dev
php license/scripts/init.php          # preferred: skips 004, records what it ran

Manual equivalent:

for f in license/migrations/[0-9][0-9][0-9]_*.sql; do
  [ -f "$f" ] || continue
  case "$(basename "$f")" in 004_*) continue ;; esac
  echo "== $f"
  mysql -u '<DB_USER>' -p '<DB>' < "$f" || break
done

Migration 011 is idempotent (safe to run multiple times).

Migration 012 is only partially idempotent: its `CREATE TABLE IF NOT EXISTS statements are safe to re-run, but the ALTER TABLE admins` block at the top adds columns unconditionally and will fail with Duplicate column name on a database that already has them. Check first:

SELECT column_name FROM information_schema.columns
 WHERE table_schema = DATABASE() AND table_name = 'admins'
   AND column_name IN ('totp_secret','failed_logins','locked_until','failed_login_at');

If all four rows come back, the ALTER block is already applied — comment it out, or use php license/scripts/init.php, which checks column presence before altering.

Backup Strategy

Automated backups are handled by license/scripts/backup.php (see DEPLOYMENT.md for the cron entry). It dumps MySQL, the legacy SQLite database and the signing keys, and prunes old copies.

The script refuses to guess the database name. With FOREN_DB_NAME / FOREN_DB_USER unset it exits 2 and writes nothing — this is deliberate. Pass them (or source an env file) before running it.

Manual equivalent, if you need a one-off dump:

mysqldump -u '<DB_USER>' -p '<DB>' \
  | gzip > /backup/foren/<DB>-$(date +%Y-%m-%d).sql.gz

# Rotation (keep 7 days)
find /backup/foren -name '<DB>-*.sql.gz' -mtime +7 -delete

Restore:

gunzip -c /backup/foren/<DB>-YYYY-MM-DD.sql.gz \
  | mysql -u '<DB_USER>' -p '<DB>'