APPSEC/i is a multi-tenant SaaS application-security and entitlements platform: it
registers tenants and provisions a private SQL schema for each, manages roles and per-user role
assignments, gates feature access through per-tenant entitlements, vetoes cross-tenant writes, and
audits every privilege change. It is a pure SQL PL application — every rule lives in DB2 for
i functions, stored procedures, statement/row triggers, a global variable, distinct types, sequences and
security views; there is no 5250 screen and no RPG driver. The application is reached through
CALL from STRSQL (or any SQL client) and is host-orchestrated by a driver that runs the
provisioning script. This manual is the reference for the operator who provisions tenants and grants
access, and for the developer maintaining the security layer. It is grounded entirely in the committed
source (sqlpl-app-sec2/src/schema.sql, routines.sql, seed.mjs, and
the test/appsec_daily.mjs driver).
SP_CHECK_ACCESS procedure and the cross-tenant veto
triggers. The application also exercises the DB2 GRANT/REVOKE privilege
surface (table, column, EXECUTE, schema-level, WITH GRANT OPTION) — but those SQL privileges are
catalog-visible (they land in SYSTABAUTH/SYSCOLAUTH) and are used here to
prove the grant/revoke bookkeeping; they are not necessarily runtime-enforced as an
authorization gate by the emulator. Every place this distinction matters is called out inline.
APPSEC/i is the security and entitlements substrate a multi-tenant SaaS platform sits on. Its responsibilities:
TENANT_ID) and
stand up a private per-tenant SQL schema, idempotently, so the platform can be re-deployed
repeatedly without collision (SP_PROVISION_TENANT + a host-orchestrated
CREATE SCHEMA).SP_GRANT_ROLE /
SP_REVOKE_ROLE).SIGNAL) when the tenant is suspended, the user is disabled, the user lacks the required
role, or the tenant is not entitled to the feature (SP_CHECK_ACCESS).TENANT_ID does not match the session's current-tenant context
(TR_RESOURCE_TENANT_VETO / _UPD).TR_USERROLE_AUDIT_INS / _DEL),
and provisioning events are logged to a security-audit table.DROP SCHEMA CASCADE,
idempotently (SP_DEPROVISION_TENANT).The application is deliberately split into two concerns that are important not to confuse:
APPSEC tables. Access decisions are computed by
SP_CHECK_ACCESS and enforced by SIGNAL; cross-tenant isolation is enforced
by the BEFORE-trigger veto against the current-tenant context. This layer does take effect at
runtime — the driver proves a denied access surfaces as a failed CALL and a
vetoed row never lands.GRANT/REVOKE at every granularity — table, column
(UPDATE(cols)/REFERENCES(cols)), EXECUTE ON PROCEDURE/FUNCTION,
CREATEIN/ALTERIN/DROPIN ON SCHEMA, and WITH GRANT OPTION — and reads
the results back out of QSYS2.SYSTABAUTH / QSYS2.SYSCOLAUTH. This proves
the grant/revoke bookkeeping is correct (a bare column-less REVOKE UPDATE
clears all UPDATE column rows for a grantee; a different privilege type survives it; a re-grant does
not duplicate a row). It does not assert those catalog privileges are checked as an
authorization gate on ordinary DML at runtime.The benefit for operations: the tenancy boundary and entitlement rules are one auditable place (the
SQL-PL routines and triggers), the same procedures are reachable from any SQL client, and the privilege
catalog is kept faithfully so an auditor can inspect who was granted what. Everything runs in library /
schema APPSEC, with per-tenant data in its own TEN… schema.
PROVISIONING ACCESS DECISION GRANT / AUDIT
------------ --------------- -------------
SP_PROVISION_TENANT SP_CHECK_ACCESS SP_GRANT_ROLE --INSERT--> USERROLE
-> TENANT row user disabled? SP_REVOKE_ROLE --DELETE--> |
-> TENANT_SEQ tenant suspended? v
-> SECAUDIT lacks role? TR_USERROLE_AUDIT_INS/_DEL -> PRIVAUDIT
(driver) CREATE SCHEMA not entitled? (AFTER STATEMENT, transition table)
-> TEN....TENDATA -> SIGNAL 75040
-> GRANT ... TO PUBLIC / QPGMR (SYSTABAUTH / SYSCOLAUTH)
SESSION CONTEXT CROSS-TENANT VETO
-------------- -----------------
SET APPSEC.CURRENT_TENANT = 'TENnnn' TR_RESOURCE_TENANT_VETO (BEFORE INSERT, 75030)
UPDATE APPSEC.SESSION_CTX (lockstep) TR_RESOURCE_TENANT_VETO_UPD (BEFORE UPDATE, 75031)
| ^
+--- driver keeps both in sync ---------+ (trigger reads SESSION_CTX, see D)
SET PATH = PATHA, PATHB --> unqualified FN_ENTITLEMENT_SOURCE() resolves PATHA (first wins)
A single access event flows: the caller sets the session's current tenant
(SET APPSEC.CURRENT_TENANT plus the lockstep SESSION_CTX update) → issues a
write to APPSEC.RESOURCE → the BEFORE trigger compares the row's TENANT_ID
against the session context and either allows it or raises 75030/75031. A role
change flows: SP_GRANT_ROLE/SP_REVOKE_ROLE mutates USERROLE →
the AFTER statement trigger writes a PRIVAUDIT row per affected assignment through the
transition table.
| Object | Type | Role |
|---|---|---|
| TENANTID | Distinct type | VARCHAR(10) tenant-id domain (WITH COMPARISONS). |
| ROLEID | Distinct type | VARCHAR(10) role-id domain (unrelated to TENANTID). |
| TENANT_SEQ / USER_SEQ | Sequence | Tenant-id / user-id generators. |
| CURRENT_TENANT | Global variable | Session current-tenant context (access predicate). |
| TENANT | Table | Tenant registry (id, name, per-tenant schema, status). |
| ROLE | Table | Role catalog. |
| APPUSER | Table | Users, each scoped to a tenant. |
| USERROLE | Table | User–role assignments (the grant log). |
| FEATURE | Table | Feature catalog. |
| TENANT_ENTITLE | Table | Which features a tenant is entitled to. |
| RESOURCE | Table | Tenant-scoped business object (veto target). |
| PRIVAUDIT | Table | Privilege-change audit (grant/revoke). |
| SESSION_CTX | Table | One-row current-tenant context read by the veto triggers. |
| SECAUDIT | Table | General security-event audit (provisioning). |
| V_ACTIVE_ENTITLEMENTS | View | Active tenants ⨯ enabled entitlements (CREATE OR REPLACE). |
| V_USER_ROLES | View | Active users ⨯ their roles. |
| SP_* (5) | SQL procedures | Provision / deprovision / grant / revoke / check-access. |
| TR_* (4) | Triggers | Audit (2, transition table) + cross-tenant veto (2). |
| FN_ENTITLEMENT_SOURCE | SQL function ×2 | PATHA/PATHB SET PATH resolution probe. |
The full catalogue is 2 distinct types + 2 sequences + 1 global variable, over 10 APPSEC
tables, 2 views, 5 procedures, 4 triggers and a pair of same-named functions in schemas
PATHA/PATHB, plus one TENDATA table per provisioned tenant schema.
Sections D and F expand each.
Honest statement: APPSEC/i has no 5250 display file, no subfile, and no interactive RPG
program. It is a pure SQL PL application. The operator equivalent of "open a screen" is "start an SQL
session and CALL a procedure". On IBM i that is STRSQL (or Run SQL Scripts / any
SQL client); in the tested environment the driver runs the same statements in-process through
executeSqlBatch / callProc. Before invoking anything, the job's library list must
include APPSEC — the tested job runs with
LIBL = QSYS QGPL APPSEC QTEMP and CURLIB = APPSEC under user
QSECOFR.
| To do this | Type in STRSQL / an SQL session |
|---|---|
| Provision (or find) a tenant + its schema | CALL APPSEC.SP_PROVISION_TENANT('Acme Corp','TENACME',?,?,?) then the driver's CREATE SCHEMA step |
| Deprovision a tenant schema | CALL APPSEC.SP_DEPROVISION_TENANT('TENACME') |
| Grant a role to a user | CALL APPSEC.SP_GRANT_ROLE('UACME01','ROLADMIN','SYSTEM',?) |
| Revoke a role | CALL APPSEC.SP_REVOKE_ROLE('UACME01','ROLADMIN',?) |
| Make an access decision | CALL APPSEC.SP_CHECK_ACCESS('UACME01','ROLADMIN','RESOURCE',?,?) |
| Set the session's current tenant | SET APPSEC.CURRENT_TENANT = 'TEN0000001' (and the lockstep SESSION_CTX UPDATE) |
| Read the entitlement gate view | SELECT * FROM APPSEC.V_ACTIVE_ENTITLEMENTS |
Because the OUT parameters (tenant id, return code, message) are what the caller inspects, an
interactive STRSQL user passes host markers (?) for them and reads the returned values; a
scripted/driver caller reads them out of the call result. There is no menu, no F-key, and no screen to
refresh — the "result" of every action is the procedure's OUT return code plus the rows it leaves in
the catalog and audit tables (section E shows the verification queries).
CREATE SCHEMA is issued by the caller immediately after
SP_PROVISION_TENANT returns a fresh tenant (confirmed platform gap
SQLPL-PLAT-APPSEC-03: CREATE SCHEMA is wired up only in the top-level batch executor, not the
routine dispatcher). Likewise the two probe schemas PATHA/PATHB are created once
from the driver. Operationally this means "provision a tenant" is a two-step call sequence, documented in
E.1.
APPSEC/i's controls are its subject matter. This section is the operator-facing summary of the security model the application enforces and records; F.5–F.7 give the developer-level detail.
SP_CHECK_ACCESS(userid, roleid, featcd) is the single gate. It returns cleanly
(P_RC = 0, 'access granted') only when all four conditions hold, and
otherwise SIGNALs condition C_DENIED (SQLSTATE 75040) with a
specific message. The checks, in the order they run (short-circuiting on the first failure):
| Check | Denial message on failure |
|---|---|
| User exists | unknown user |
User is active (USTAT='A') | user is disabled |
Tenant is active (TSTAT='A') | tenant is suspended |
User holds the required role (USERROLE) | user lacks required role |
Tenant is entitled to the feature (ENSTAT='Y') | tenant not entitled to feature |
A denial surfaces to the caller as a failed CALL (non-zero code), never a silent grant — the driver proves each denial path explicitly.
The session's current tenant is held in the global variable APPSEC.CURRENT_TENANT and, in
lockstep, in the one-row APPSEC.SESSION_CTX table. When a current tenant is set, any INSERT or
UPDATE on APPSEC.RESOURCE whose TENANT_ID does not match is refused by the BEFORE
triggers with SQLSTATE 75030 (insert) or 75031 (update); a same-tenant write is
allowed. With no current tenant set (NULL), the veto is inert and all writes pass. See D and F.4 for the
documented reason the trigger reads SESSION_CTX rather than the global variable directly.
The application issues, and reads back, DB2 privileges at every granularity. These are recorded in the authorization catalog; they are the auditable record of who was granted what:
| Granularity | Example statement | Catalog |
|---|---|---|
| Table privilege | GRANT SELECT, INSERT ON APPSEC.RESOURCE TO QPGMR | SYSTABAUTH |
| Column UPDATE | GRANT UPDATE (SENSITIVE, RESNM) ON APPSEC.RESOURCE TO QPGMR | SYSCOLAUTH |
| Column REFERENCES | GRANT REFERENCES (RESID) ON APPSEC.RESOURCE TO QPGMR | SYSCOLAUTH |
| EXECUTE on procedure | GRANT EXECUTE ON PROCEDURE APPSEC.SP_CHECK_ACCESS TO QPGMR WITH GRANT OPTION | SYSTABAUTH (OBJECT_TYPE='PROCEDURE') |
| EXECUTE on function | GRANT EXECUTE ON FUNCTION APPSEC.FN_ENTITLEMENT_SOURCE TO PUBLIC | SYSTABAUTH |
| Schema privileges | GRANT CREATEIN, ALTERIN, DROPIN ON SCHEMA TENACME TO QPGMR WITH GRANT OPTION | SYSTABAUTH (OBJECT_TYPE='SCHEMA') |
| Grant to PUBLIC | GRANT SELECT ON TENDATA TO PUBLIC (inside the per-tenant CREATE SCHEMA) | SYSTABAUTH (GRANTEE='*PUBLIC') |
The DB2 revoke rules are exercised and honoured in the catalog: a column-scoped
REVOKE UPDATE (SENSITIVE) leaves the other UPDATE column intact; a bare
REVOKE UPDATE (no column list) clears all remaining UPDATE column rows for that
grantee but leaves a different privilege type (REFERENCES) and the table-level
INSERT grant untouched; a re-GRANT of the identical privilege to the identical
grantee does not duplicate the row; REVOKE EXECUTE clears the procedure row.
IS_GRANTABLE='YES'. This is the privilege bookkeeping. It is not a claim
that the emulator refuses ordinary DML at runtime for a grantee lacking the privilege. Runtime
authorization in APPSEC/i is the data-enforced model above (SP_CHECK_ACCESS +
cross-tenant veto), which the driver proves takes effect.
Two identically-named functions FN_ENTITLEMENT_SOURCE() live in schemas
PATHA and PATHB, each returning its own schema tag. A qualified call always
resolves to the named schema; an unqualified call resolves by the current PATH
— SET PATH = PATHA, PATHB makes the unqualified call return PATHA, and
re-ordering to SET PATH = PATHB, PATHA flips it to PATHB. The
CURRENT PATH register reflects the reordered list. This is how per-tenant schema resolution
order is expressed. See F.6.
APPSEC/i has no periodic accounting "close" — it is an operational security platform, so its
"cycle" is the provisioning & entitlement lifecycle: deploy the schema and routines, provision
tenants, seed roles/features, grant and revoke, check access, and (when a tenant leaves) deprovision. All
of it is driven by CALL against the SQL-PL procedures and the security triggers that fire off
the resulting table changes. There is no control row and no parameterless SBMJOB idiom — every action
is an explicit call with its own parameters.
-- deploy (idempotent for OR-REPLACE objects; TYPE/TABLE/SEQ/VAR fail "already exists" on re-run) RUNSQLSTM SRCFILE(APPSEC/SRC) SRCMBR(SCHEMA) -- schema.sql RUNSQLSTM SRCFILE(APPSEC/SRC) SRCMBR(ROUTINES) -- routines.sql -- provision a tenant (two-step: procedure, then host-issued CREATE SCHEMA) CALL APPSEC.SP_PROVISION_TENANT('Acme Corp','TENACME',?,?,?); CREATE SCHEMA TENACME CREATE TABLE TENDATA (TID VARCHAR(10) NOT NULL, TVAL VARCHAR(60)) GRANT SELECT ON TENDATA TO PUBLIC; GRANT CREATEIN, ALTERIN, DROPIN ON SCHEMA TENACME TO QPGMR WITH GRANT OPTION;
| Object | Purpose | Calls / fires | Inputs | Outputs / effects | Idempotency |
|---|---|---|---|---|---|
| SP_PROVISION_TENANT | Register a tenant (sequence-generated id) if the name is new. | NEXT VALUE FOR TENANT_SEQ; INSERT TENANT + SECAUDIT. |
P_TENANTNM, P_SCHEMANM. | OUT P_TENANTID, P_RC (0 fresh / 1 existed / 9 error), P_MSG; TENANT + SECAUDIT rows. | Idempotent by tenant name (second call RC=1, no dup row). |
| (driver) CREATE SCHEMA | Stand up the per-tenant schema + TENDATA + PUBLIC/QPGMR grants. | Top-level CREATE SCHEMA / GRANT (host-orchestrated). | Schema name; only on a FRESH provision (RC=0). | TEN… schema, TENDATA table, SYSTABAUTH grant rows. | Only issued when SP_PROVISION_TENANT reports fresh, so not re-run for an existing tenant. |
| SP_DEPROVISION_TENANT | Tear a tenant schema down (DROP SCHEMA CASCADE). | EXECUTE IMMEDIATE DROP SCHEMA … CASCADE, guarded by a SYSSCHEMAS existence probe. | P_SCHEMANM. | Schema and its contents removed. | Idempotent (IF-EXISTS guard; re-run on a gone schema is a clean no-op). |
| SP_GRANT_ROLE | Assign a role to a user. | Guarded INSERT into USERROLE → fires TR_USERROLE_AUDIT_INS. |
P_USERID, P_ROLEID, P_GRANTEDBY. | OUT P_RC (0 granted / 1 already granted); USERROLE row; PRIVAUDIT ROLE_GRANT row. | Idempotent (existence check; a no-op re-grant does NOT re-fire the trigger). |
| SP_REVOKE_ROLE | Remove a role assignment. | Guarded DELETE from USERROLE → fires TR_USERROLE_AUDIT_DEL. |
P_USERID, P_ROLEID. | OUT P_RC (0 revoked / 1 nothing to revoke); PRIVAUDIT ROLE_REVOKE row. | Idempotent (a no-op re-revoke adds no audit row). |
| SP_CHECK_ACCESS | The entitlement gate (allow / SIGNAL denial). | SELECT INTO over APPUSER/TENANT/USERROLE/TENANT_ENTITLE; SIGNAL 75040 on any failure. | P_USERID, P_ROLEID, P_FEATCD. | OUT P_RC=0 + 'access granted', or a failed CALL with a denial message. | Read-only (no state change); always safe to re-run. |
| TR_USERROLE_AUDIT_INS | Audit every role grant. | AFTER INSERT ON USERROLE, FOR EACH STATEMENT, REFERENCING NEW TABLE. | Transition table NT. | One PRIVAUDIT ROLE_GRANT row per inserted assignment (set-based INSERT..SELECT). | Fires once per statement; multi-row batches audited in one INSERT. |
| TR_USERROLE_AUDIT_DEL | Audit every role revoke. | AFTER DELETE ON USERROLE, FOR EACH STATEMENT, REFERENCING OLD TABLE. | Transition table OT. | One PRIVAUDIT ROLE_REVOKE row per deleted assignment. | Fires once per statement. |
| TR_RESOURCE_TENANT_VETO | Refuse a cross-tenant INSERT. | BEFORE INSERT ON RESOURCE, FOR EACH ROW, WHEN SESSION_CTX set & mismatched. | NEW row TENANT_ID vs SESSION_CTX.CUR_TENANT. | SIGNAL SQLSTATE 75030; the row never lands. | Deterministic per row. |
| TR_RESOURCE_TENANT_VETO_UPD | Refuse a cross-tenant UPDATE. | BEFORE UPDATE ON RESOURCE, FOR EACH ROW, WHEN OLD row not owned by current tenant. | OLD row TENANT_ID vs SESSION_CTX.CUR_TENANT. | SIGNAL SQLSTATE 75031; the row is unchanged. | Deterministic per row. |
The procedure counts existing tenants of the given name. If one exists it returns that tenant's id with
P_RC=1 ('tenant already provisioned'); otherwise it draws the next
TENANT_SEQ value, formats a TEN… id ('TEN' || LPAD(CHAR(seq),7,'0'),
e.g. TEN0000001), inserts the TENANT row and a PROVISION_OK SECAUDIT row, and
returns P_RC=0. An EXIT HANDLER traps any SQLEXCEPTION, sets P_RC=9, captures the
message via GET DIAGNOSTICS, and writes a PROVISION_FAIL SECAUDIT row. The
per-tenant CREATE SCHEMA is then issued by the caller only when RC=0 (fresh), so a
re-provision of the same name touches neither the catalog nor the schema.
Expected result (Acme, fresh then re-run):
call 1: P_TENANTID = TEN0000001, P_RC = 0 ('provisioned')
call 2: P_TENANTID = TEN0000001, P_RC = 1 ('tenant already provisioned')
invariant: exactly one TENANT row for 'Acme Corp'
Both procedures guard their mutation with an existence check, so a re-grant (P_RC=1) and a
re-revoke (P_RC=1) are clean no-ops. The audit is trigger-driven, not coded in the
procedure: the AFTER-statement triggers use transition tables (NEW TABLE /
OLD TABLE) to write one PRIVAUDIT row per affected assignment in a single
set-based INSERT…SELECT. Because they are statement-level and only fire when a row
actually changes, an idempotent no-op re-grant does not add a spurious audit row.
Expected (grant ROLADMIN to UACME01, then re-grant):
grant 1: P_RC = 0 1 USERROLE row, 1 PRIVAUDIT ROLE_GRANT row
grant 2: P_RC = 1 no new USERROLE row, no new audit row
PRIVAUDIT ROLE_GRANT for UACME01/ROLADMIN carries EVTBY = 'SYSTEM' (from GRANTEDBY, via NT)
The gate short-circuits on the first failing condition (see B.2). A denial is a SIGNAL of
condition C_DENIED (75040), which surfaces to the caller as a non-zero call code plus the
denial message — never a silent grant. Only when unknown-user, disabled-user, suspended-tenant,
missing-role and un-entitled-feature all pass does it set P_RC=0 / 'access granted'.
Probes QSYS2.SYSSCHEMAS for the schema and, if present, issues
DROP SCHEMA … CASCADE via EXECUTE IMMEDIATE (which, unlike CREATE SCHEMA,
does run from inside a routine body). A DROP SCHEMA … RESTRICT against a
non-empty per-tenant schema is refused ("not empty"); CASCADE removes the schema and its contents. Note
this drops only the tenant's schema — it deliberately does not delete the
APPSEC.TENANT registry row (a real platform would archive that separately).
SESSION_CTX declared in schema.sql; deploy the
schema first.FN_ENTITLEMENT_SOURCE functions
target schemas PATHA/PATHB, which the driver creates once before loading
the routines (they cannot be created idempotently inside the reloadable routines file).SP_GRANT_ROLE references a
ROLE; SP_CHECK_ACCESS references a FEATURE entitlement. Load
seed.mjs (3 roles, 3 features, the empty SESSION_CTX row) first.APPUSER.TENANT_ID), so the
tenant must exist before its users and their role grants.SESSION_CTX.CUR_TENANT is set; keep CURRENT_TENANT and
SESSION_CTX in lockstep (the driver always updates both together).All core objects are in schema/library APPSEC, grounded in schema.sql. Dates
are stored as INT in YYYYMMDD form; the tenant/role id columns use the distinct
types APPSEC.TENANTID / APPSEC.ROLEID (both over VARCHAR(10)).
Per-tenant data lives in each tenant's own TEN… schema, not in APPSEC.
| Field | Type | Meaning |
|---|---|---|
| TENANT_ID | TENANTID (VARCHAR(10)) | Tenant id (PK), e.g. TEN0000001 (sequence-generated). |
| TENANTNM | VARCHAR(60) | Tenant name; provisioning is idempotent by this name. |
| SCHEMANM | VARCHAR(18) | The per-tenant SQL schema/library name (e.g. TENACME). |
| TSTAT | CHAR(1) | A active, S suspended (a suspended tenant fails SP_CHECK_ACCESS). |
| CRTDATE | INT | Created date (YYYYMMDD). |
| Field | Type | Meaning |
|---|---|---|
| ROLE_ID | ROLEID (VARCHAR(10)) | Role id (PK), e.g. ROLADMIN. |
| ROLENM | VARCHAR(40) | Role name. |
| RDESC | VARCHAR(100) | Description. |
Seeded roles: ROLADMIN (Administrator), ROLVIEWER (Viewer),
ROLEDITOR (Editor).
| Field | Type | Meaning |
|---|---|---|
| USERID | VARCHAR(10) | User id (PK). |
| TENANT_ID | TENANTID | Owning tenant. |
| USERNM | VARCHAR(60) | User name. |
| USTAT | CHAR(1) | A active, D disabled (a disabled user fails SP_CHECK_ACCESS). |
| Field | Type | Meaning |
|---|---|---|
| USERID / ROLE_ID | VARCHAR(10) / ROLEID | The assignment (PK). |
| GRANTEDBY | VARCHAR(10) | Who granted it (flows to PRIVAUDIT.EVTBY via the trigger). |
| GRANTDATE | INT | Grant date (YYYYMMDD). |
INSERT here fires TR_USERROLE_AUDIT_INS; DELETE fires TR_USERROLE_AUDIT_DEL.
| Field | Type | Meaning |
|---|---|---|
| FEATCD | CHAR(8) | Feature code (PK), e.g. RESOURCE, REPORTS , AUDITLOG. |
| FEATNM | VARCHAR(60) | Feature name. |
Because FEATCD is CHAR(8), a shorter code is space-padded —
the seed uses 'REPORTS ' (trailing blank).
| Field | Type | Meaning |
|---|---|---|
| TENANT_ID / FEATCD | TENANTID / CHAR(8) | Tenant + feature (PK). |
| ENSTAT | CHAR(1) | Y enabled, N disabled. SP_CHECK_ACCESS requires an existing row with ENSTAT='Y'. |
| Field | Type | Meaning |
|---|---|---|
| RESID | VARCHAR(12) | Resource id (PK). |
| TENANT_ID | TENANTID | Owning tenant — compared to the session context by the veto triggers. |
| RESNM | VARCHAR(60) | Resource name. |
| SENSITIVE | VARCHAR(60) | The column that receives column-level UPDATE/REFERENCES grants. |
| RSTAT | CHAR(1) | Status (default A). |
| Field | Type | Meaning |
|---|---|---|
| AUDID | INT identity | Generated-always audit id (PK). |
| EVTTYPE | VARCHAR(12) | ROLE_GRANT or ROLE_REVOKE. |
| USERID / ROLE_ID | VARCHAR(10) / ROLEID | The affected assignment. |
| EVTBY | VARCHAR(10) | Who did it (GRANTEDBY on grant; NULL on revoke). |
| EVTDATE | INT | Event date (YYYYMMDD). |
| EVTTIME | TIMESTAMP | Default CURRENT_TIMESTAMP (underscore spelling — see note below). |
| Field | Type | Meaning |
|---|---|---|
| CTXKEY | CHAR(1) | Always 'X' (PK, single row). |
| CUR_TENANT | VARCHAR(10) | The current tenant the veto triggers read. |
APPSEC.CURRENT_TENANT global variable directly. This is a documented app-level workaround for
a confirmed platform gap (SQLPL-PLAT-APPSEC-05): a trigger's WHEN clause (and the other
non-exec() SQL-PL read paths) cannot resolve a CREATE VARIABLE global-variable
reference. The driver keeps SESSION_CTX in exact lockstep with CURRENT_TENANT on
every set. The global variable is still exercised for real through the working top-level batch path
(SET / VALUES APPSEC.CURRENT_TENANT).| Field | Type | Meaning |
|---|---|---|
| AUDID | INT identity | Generated-always id (PK). |
| EVTTYPE | VARCHAR(20) | e.g. PROVISION_OK / PROVISION_FAIL. |
| REFDOC | VARCHAR(20) | Reference (schema/tenant id). |
| EVTTEXT | VARCHAR(200) | Human-readable detail. |
| EVTDATE / EVTTIME | INT / TIMESTAMP | When (date + default CURRENT_TIMESTAMP). |
V_ACTIVE_ENTITLEMENTS joins TENANT ⨯ TENANT_ENTITLE ⨯ FEATURE,
filtered to TSTAT='A' and ENSTAT='Y' — the effective entitlement gate list.
V_USER_ROLES joins APPUSER ⨯ USERROLE ⨯ ROLE for active users. Both
are deployed via CREATE OR REPLACE VIEW, so the daily deploy can redeploy them idempotently
without dropping dependents.
CREATE TYPE APPSEC.TENANTID AS VARCHAR(10) WITH COMPARISONS and
APPSEC.ROLEID similarly — two unrelated distinct types over the identical source, used
to probe whether cross-type assignment is rejected (see F.1). TENANT_SEQ / USER_SEQ
are AS INT START WITH 1 INCREMENT BY 1 NO CYCLE generators. CURRENT_TENANT is a
CREATE VARIABLE APPSEC.CURRENT_TENANT TENANTID DEFAULT NULL global (the type is spelled
unqualified as an app-level workaround for SQLPL-PLAT-APPSEC-01).
Each provisioned tenant gets its own SQL schema named by SCHEMANM, created (by the driver)
with a contained TENDATA (TID VARCHAR(10), TVAL VARCHAR(60)) table and a
GRANT SELECT ON TENDATA TO PUBLIC, plus a schema-level
GRANT CREATEIN, ALTERIN, DROPIN … TO QPGMR WITH GRANT OPTION. This is where a tenant's
own data would live, isolated from every other tenant's schema.
DEFAULT CURRENT_TIMESTAMP (not the two-word DEFAULT CURRENT
TIMESTAMP) as an app-level workaround for a confirmed platform gap (SQLPL-PLAT-APPSEC-02). These
are honest, source-documented accommodations; operationally the columns default to the insert time as
described.schema.sql then routines.sql loaded, the
PATHA/PATHB probe schemas created, and the seed run (3 roles, 3 features,
the empty SESSION_CTX row).CREATE SCHEMA is host-issued only
on a fresh provision):CALL APPSEC.SP_PROVISION_TENANT('Acme Corp','TENACME',?,?,?);
-- if the OUT P_RC came back 0 (fresh), issue the per-tenant schema:
CREATE SCHEMA TENACME
CREATE TABLE TENDATA (TID VARCHAR(10) NOT NULL, TVAL VARCHAR(60))
GRANT SELECT ON TENDATA TO PUBLIC;
GRANT CREATEIN, ALTERIN, DROPIN ON SCHEMA TENACME TO QPGMR WITH GRANT OPTION;
Verify:
TEN0000001; P_RC=0 ('provisioned') on a fresh
name, P_RC=1 ('tenant already provisioned') on a repeat.SELECT COUNT(*) FROM APPSEC.TENANT WHERE TENANTNM='Acme Corp' → 1.SELECT COUNT(*) FROM QSYS2.SYSSCHEMAS WHERE SCHEMA_NAME='TENACME' → 1, and SELECT COUNT(*) FROM TENACME.TENDATA answers (0 rows).TENDATA in SYSTABAUTH,
and the schema-level CREATEIN/ALTERIN/DROPIN for QPGMR with
IS_GRANTABLE='YES' (for a schema grant, the schema name lands in
SYSTABAUTH.TABLE_NAME with OBJECT_TYPE='SCHEMA').TEN… ids and ten schemas. A whole-platform
redeploy (reload schema + routines + re-seed + re-provision) is safe end to end; the only errors on a
naive schema re-run are the expected "already exists" (SQL0601) on the non-OR-REPLACE creates (the two
types, two sequences, one variable, and ten tables), while the CREATE OR REPLACE VIEWs
reload clean.INSERT INTO APPSEC.APPUSER (USERID, TENANT_ID, USERNM, USTAT) VALUES ('UACME01','TEN0000001','Alice','A').CALL APPSEC.SP_GRANT_ROLE('UACME01','ROLADMIN','SYSTEM',?) → expect P_RC=0.INSERT INTO APPSEC.TENANT_ENTITLE (TENANT_ID, FEATCD, ENSTAT) VALUES ('TEN0000001','RESOURCE','Y').CALL APPSEC.SP_CHECK_ACCESS('UACME01','ROLADMIN','RESOURCE',?,?) → expect P_RC=0, 'access granted'.Negative paths to confirm the gate really gates (each returns a failed CALL with the named message, never a silent grant):
| Scenario | Expected denial message |
|---|---|
| User lacks the required role | user lacks required role |
Feature entitlement disabled (ENSTAT='N') | tenant not entitled to feature |
Disabled user (USTAT='D') | user is disabled |
| Unknown user | unknown user |
Suspended tenant (TSTAT='S') | tenant is suspended |
Cross-tenant write test — set the session context and prove isolation:
SET APPSEC.CURRENT_TENANT = 'TEN0000001'; UPDATE APPSEC.SESSION_CTX SET CUR_TENANT = 'TEN0000001' WHERE CTXKEY='X'; -- same-tenant INSERT is allowed: INSERT INTO APPSEC.RESOURCE (RESID,TENANT_ID,RESNM,SENSITIVE) VALUES ('RES00001','TEN0000001','Acme Widget','secret-a'); -- cross-tenant INSERT is VETOED (SQLSTATE 75030); the row never lands: INSERT INTO APPSEC.RESOURCE (RESID,TENANT_ID,RESNM,SENSITIVE) VALUES ('RES00002','TEN0000002','Globex Widget','secret-b');
Audit verification: after N grants and M revokes,
SELECT COUNT(*) FROM APPSEC.PRIVAUDIT WHERE EVTTYPE='ROLE_GRANT' = N and
...='ROLE_REVOKE' = M — an idempotent no-op grant/revoke adds nothing. The grant's
EVTBY equals the GRANTEDBY supplied to SP_GRANT_ROLE.
Every procedure returns an OUT return code; a healthy call ends P_RC=0 (acted) or
P_RC=1 (idempotent no-op). A denial or error surfaces as a non-zero call code with a message
(section F.7).
| Situation | Behaviour | Action |
|---|---|---|
| Re-provision same tenant name | Returns the existing id, P_RC=1; no dup row, no dup schema. | Safe no-op. Idempotent by name. |
| Provision failure | EXIT HANDLER sets P_RC=9, writes a PROVISION_FAIL SECAUDIT row with the captured message. | Read the SECAUDIT row, fix the cause, re-run. |
| Re-grant same user/role | P_RC=1; no USERROLE row, no new PRIVAUDIT row (trigger does not re-fire). | Safe no-op. |
| Re-revoke (nothing to revoke) | P_RC=1; no new audit row. | Safe no-op. |
| SP_CHECK_ACCESS denial | SIGNAL 75040; call code non-zero, message names the reason; no state changed. | Correct the user/role/entitlement/tenant status and retry. |
| Cross-tenant write | Trigger SIGNAL 75030/75031; the row never lands / is unchanged. | Set the correct current tenant (both CURRENT_TENANT and SESSION_CTX) and retry. |
| DROP SCHEMA RESTRICT on a non-empty tenant schema | Refused ("not empty"); the schema stays. | Use SP_DEPROVISION_TENANT (CASCADE) to tear it down. |
| Deprovision an already-gone schema | IF-EXISTS guard makes it a clean no-op. | Safe. Note the TENANT registry row persists (schema-only deprovision). |
| Naive schema.sql re-run | Exactly the non-OR-REPLACE creates fail "already exists" (SQL0601); views reload clean. | Expected. Use the redeploy drill; no other error type should appear. |
PRIVAUDIT (grant/revoke, by whom)
and every provisioning event to SECAUDIT, the security posture is fully reconstructable after
the fact for reconciliation and audit.The complete SQL-PL and privilege surface, from schema.sql and routines.sql.
Core objects are in schema APPSEC; the resolution-probe functions are in PATHA /
PATHB.
TENANT.TENANT_ID, APPUSER.TENANT_ID,
RESOURCE.TENANT_ID, etc.ROLEID value into a TENANTID position and observe whether the
engine rejects the cross-type assignment. Documented deviation: strong typing / CAST identity
is not modeled by the platform (SQLPL-COVERAGE.md section E), so the assignment is
accepted rather than rejected — the app proves that boundary rather than assuming it.SP_PROVISION_TENANT draws NEXT VALUE FOR APPSEC.TENANT_SEQ and
formats 'TEN' || LPAD(CHAR(seq),7,'0'). Sequence-generation is what makes repeated
provisioning collision-free.CREATE VARIABLE parser does not accept a schema-qualified distinct-type name in the type
position, so the type is spelled unqualified (TENANTID, not
APPSEC.TENANTID). The variable reads/writes correctly through the top-level batch path
(SET / VALUES APPSEC.CURRENT_TENANT); the veto triggers read the parallel
SESSION_CTX table instead (see F.4).QSYS2.SYSSEQUENCES, the variable in QSYS2.SYSVARIABLES, and both distinct types
in QSYS2.SYSTYPES with SOURCE_TYPE='VARCHAR(10)'.'PATHA'.'PATHB'. Two identically-named functions in two schemas, used to
prove unqualified-call resolution follows SET PATH (F.6). Both are
CREATE OR REPLACE FUNCTION, so they reload idempotently; only their containing schemas
are non-idempotent (driver-created once).P_RC=1.
Otherwise draws TENANT_SEQ, inserts the TENANT + a PROVISION_OK SECAUDIT row,
P_RC=0. An EXIT HANDLER FOR SQLEXCEPTION sets P_RC=9, captures the
message via GET DIAGNOSTICS CONDITION 1 ... = MESSAGE_TEXT, and writes a
PROVISION_FAIL SECAUDIT row. Does not itself issue CREATE SCHEMA
(SQLPL-PLAT-APPSEC-03: CREATE SCHEMA is not recognized by the routine dispatcher; the caller issues
it top-level on a fresh provision).QSYS2.SYSSCHEMAS; if the schema exists,
EXECUTE IMMEDIATE 'DROP SCHEMA ' || P_SCHEMANM || ' CASCADE'. DROP SCHEMA does
run from inside a routine (unlike CREATE SCHEMA). Idempotent via the existence probe.USERROLE; P_RC=0 granted, 1
already granted. The INSERT fires TR_USERROLE_AUDIT_INS.
Workaround (SQLPL-PLAT-APPSEC-04): P_ROLEID is declared VARCHAR(10),
not the ROLEID distinct type, because a CALL argument bound to a character-sourced
distinct-type IN parameter is spuriously rejected with SQL0302 (callProc0's isCharType()
inspects the raw declared-type text rather than the resolved source type).USERROLE; P_RC=0 revoked, 1
nothing to revoke. Fires TR_USERROLE_AUDIT_DEL. Same VARCHAR(10) workaround on
P_ROLEID.C_DENIED FOR SQLSTATE '75040' and
SELECT INTOs the user (tenant + status), tenant status, role count and entitlement
status, SIGNAL C_DENIEDing with a specific MESSAGE_TEXT at the first
failure: unknown user → disabled user → suspended tenant → missing role →
un-entitled feature. Clean pass → P_RC=0, 'access granted'. Same
VARCHAR(10) workaround on P_ROLEID.BEGIN ATOMIC INSERT INTO PRIVAUDIT (...) SELECT 'ROLE_GRANT', NT.USERID, NT.ROLE_ID, NT.GRANTEDBY, NT.GRANTDATE FROM NT;
— one audit row per inserted assignment in a single set-based statement (proves multi-row
transition-table batching, not FOR EACH ROW).INSERT ... SELECT 'ROLE_REVOKE', OT.USERID, OT.ROLE_ID, NULL, 20260807 FROM OT; —
one ROLE_REVOKE row per deleted assignment (EVTBY NULL on revoke).SESSION_CTX AND the new row's TENANT_ID does
not match → SIGNAL SQLSTATE '75030' ("cross-tenant write refused"). Reads
SESSION_CTX, not the global variable (below).OLD.TENANT_ID) is not owned by the current tenant →
SIGNAL SQLSTATE '75031'.WHEN clause — and every SQL-PL read-context except plExecStmt's
exec() path (SELECT INTO, cursor queries, scalar IF-conditions, WHEN clauses) — cannot
resolve a CREATE VARIABLE global-variable reference; they route through
host.query()/host.eval()/whenPasses(), none of which apply
gvarRewrite. A WHEN clause referencing the global variable is silently never true
(whenPasses fails closed on the throw — which for a VETO trigger means it would fail
open, a genuine security-relevant finding). The app therefore points the WHEN clause at the real
one-row SESSION_CTX table (ordinary table reads work correctly), and the driver keeps
SESSION_CTX in exact lockstep with CURRENT_TENANT. This is honestly documented in
the source; operationally the veto behaves as described (a cross-tenant row is refused, a same-tenant row
is allowed).Every DB2 privilege granularity the application exercises, with the catalog it lands in and the behaviour the driver verifies. All of this is catalog bookkeeping (A.2 enforcement caveat); the runtime authorization gate is the data-enforced model in F.3–F.4.
| Statement | Catalog row observed | Verified behaviour |
|---|---|---|
| GRANT SELECT, INSERT ON APPSEC.RESOURCE TO QPGMR | SYSTABAUTH (TABLE_SCHEMA='APPSEC', GRANTEE='QPGMR') | Table-level privilege lands. |
| GRANT UPDATE (SENSITIVE, RESNM) ON APPSEC.RESOURCE TO QPGMR | SYSCOLAUTH, one row per column, PRIVILEGE_TYPE='UPDATE' | Both columns appear. |
| GRANT REFERENCES (RESID) ON APPSEC.RESOURCE TO QPGMR | SYSCOLAUTH, COLUMN_NAME='RESID', PRIVILEGE_TYPE='REFERENCES' | Column REFERENCES lands. |
| REVOKE UPDATE (SENSITIVE) ON APPSEC.RESOURCE FROM QPGMR | SYSCOLAUTH now shows only RESNM for UPDATE | Column-scoped revoke removes just that column; the other UPDATE column and REFERENCES survive. |
| REVOKE UPDATE ON APPSEC.RESOURCE FROM QPGMR | SYSCOLAUTH UPDATE rows → 0 for QPGMR | Bare revoke (no column list) clears ALL remaining UPDATE columns (the DB2 rule); REFERENCES and table-level INSERT untouched. |
| GRANT EXECUTE ON PROCEDURE APPSEC.SP_CHECK_ACCESS TO QPGMR WITH GRANT OPTION | SYSTABAUTH OBJECT_TYPE='PROCEDURE', IS_GRANTABLE='YES' | EXECUTE-on-procedure + grant option recorded; a re-grant does NOT duplicate the row. |
| GRANT EXECUTE ON FUNCTION APPSEC.FN_ENTITLEMENT_SOURCE TO PUBLIC | SYSTABAUTH GRANTEE='*PUBLIC' | EXECUTE-on-function to PUBLIC recorded. |
| REVOKE EXECUTE ON PROCEDURE APPSEC.SP_CHECK_ACCESS FROM QPGMR | SYSTABAUTH EXECUTE row → 0 | Revoke clears the procedure privilege. |
| GRANT CREATEIN, ALTERIN, DROPIN ON SCHEMA TENACME TO QPGMR WITH GRANT OPTION | SYSTABAUTH OBJECT_TYPE='SCHEMA', TABLE_NAME='TENACME' | All three schema privileges, IS_GRANTABLE='YES'. For a schema grant the schema NAME lands in TABLE_NAME (not TABLE_SCHEMA). |
| GRANT SELECT ON TENDATA TO PUBLIC (inside CREATE SCHEMA) | SYSTABAUTH TABLE_SCHEMA='TENACME', GRANTEE='*PUBLIC' | Contained-element PUBLIC grant lands with the schema. |
The revoke rules proven, in words: a column-scoped revoke removes only the named columns; a bare
REVOKE UPDATE removes all remaining UPDATE columns for the grantee; a
different privilege type (REFERENCES) and the table-level grant survive an UPDATE revoke; and
re-granting the identical privilege to the identical grantee is a no-op in the catalog (no duplicate row).
| Statement | Unqualified FN_ENTITLEMENT_SOURCE() resolves to |
|---|---|
| VALUES PATHA.FN_ENTITLEMENT_SOURCE() | PATHA (qualified — always, regardless of PATH) |
| VALUES PATHB.FN_ENTITLEMENT_SOURCE() | PATHB (qualified) |
| SET PATH = PATHA, PATHB | PATHA (first schema in the path wins) |
| SET PATH = PATHB, PATHA | PATHB (re-ordering flips resolution) |
| VALUES CURRENT PATH | "PATHB","PATHA" (the register reflects the reordered list) |
This is the mechanism per-tenant schema resolution order would ride on: an unqualified routine name
binds to the first schema on the PATH that defines it, and SET PATH re-orders
which wins.
| SQLSTATE | Raised by | Meaning |
|---|---|---|
| 75030 | TR_RESOURCE_TENANT_VETO | Cross-tenant INSERT refused: resource TENANT_ID does not match CURRENT_TENANT. |
| 75031 | TR_RESOURCE_TENANT_VETO_UPD | Cross-tenant UPDATE refused: existing resource does not belong to CURRENT_TENANT. |
| 75040 | SP_CHECK_ACCESS (condition C_DENIED) | Access denied. The MESSAGE_TEXT names the specific reason: unknown user / user is disabled / tenant is suspended / user lacks required role / tenant not entitled to feature. |
OUT P_RC: 0 = acted (provisioned / granted / revoked / access granted),
1 = idempotent no-op (already existed / already granted / nothing to revoke),
9 = SP_PROVISION_TENANT caught an SQLEXCEPTION (details in the
PROVISION_FAIL SECAUDIT row).TENANTID / ROLEID over
VARCHAR(10)). Nominally scopes a domain; note the platform does not model strong CAST
identity, so cross-type assignment is accepted, not rejected.TENANT_ENTITLE, ENSTAT='Y'). A tenant can act on
a feature only when entitled, checked by SP_CHECK_ACCESS.APPSEC.CURRENT_TENANT) holding the current-tenant context.
Kept in lockstep with the SESSION_CTX table the veto triggers read.TENANT) and a private
SQL schema (SCHEMANM); cross-tenant writes are vetoed.ROLE) and its assignment to a user (USERROLE),
granted/revoked through procedures and audited by triggers.SP_CHECK_ACCESS SIGNALs
75040 on denial; the veto triggers SIGNAL 75030/75031.P_RC 0 or 1; a denial or
veto surfaces as a failed call carrying an application SQLSTATE (F.7).CALL from STRSQL or any SQL client.