On this page

Validate it · docs/validation/iq-protocol.md

Installation qualification protocol

Document id CS-VAL-06
Title Installation qualification (IQ) protocol
Version 1.0
Applies to Countersign 1.0.0
Status Template supplied with the software; to be completed, reviewed and approved by the regulated user

Approval of the completed document:

Role Name Signature Date
Prepared by
Reviewed by
Approved by (quality assurance)

1. Purpose and scope

This protocol documents that Countersign is installed and configured as specified in one environment. Execute one copy per environment (test, production). It covers the server path with PostgreSQL and OpenID Connect sign-in, which is the recommended production arrangement; where you use SQLite or a reverse proxy, the alternative steps are marked.

The authoritative installation instructions are Deployment, Storage and Authentication. This protocol checks the result; it does not replace them.

Item Entry
Environment (test or production)
Application address (public URL)
Application server host name(s)
Database server host name and database name
Executed by (name, function)
Witnessed or reviewed by (name, function)
Start date

2. Rules for execution

  • Record the actual result in words or attach a numbered printout or screenshot. "As expected" is acceptable only where the expected result is a fixed text.
  • Record values that identify what was installed (versions, hashes). Never record secrets: for a secret, record only "set" and where it is kept.
  • Mark each step P (pass) or F (fail), with initials and date. A failed step needs a deviation (validation plan, section 8).
  • Commands are written for the standalone repository. countersign stands for npm run cli -- (or node scripts/countersign.ts), run in the application directory with the same COUNTERSIGN_* environment as the server.

3. Prerequisites

No. Prerequisite Evidence or reference P/F Initials, date
3.1 Validation plan, user requirements and risk assessment are approved
3.2 The configuration document is approved; its SHA-256 from countersign config validate <file> is recorded in the approval
3.3 Server, operating system and network are qualified or under your infrastructure procedure
3.4 PostgreSQL 12 or newer is installed and operated by the database team
3.5 The application is registered in the identity provider (redirect address <public URL>/auth/callback), roles or groups exist
3.6 The release to install is identified (tag and commit) and was obtained from the project's repository
3.7 The person executing is trained and has the necessary access; the database owner credentials are with the database administrator only

4. Versions and identification

No. Instruction Expected result Actual result P/F Initials, date
4.1 On each application server run node --version 22.18 or newer
4.2 On the database run SELECT version(); PostgreSQL 12 or newer
4.3 In the application directory run git describe --tags --always and git rev-parse HEAD (or record the checksum of the release archive) The approved release: tag ______, commit ______
4.4 Run git status --porcelain (if installed from the repository) No output: no local modification of the source
4.5 Compute the checksum of the schema file: sha256sum db/postgres.sql Recorded; equal on every server and equal to the file in the approved release
4.6 Record the installed dependency tree: npm ls --all > npm-ls.txt and npm sbom --sbom-format cyclonedx > sbom.json; attach both Files produced without errors about missing or extraneous packages
4.7 Run npm audit --omit=dev and attach the output No known vulnerability rated high or critical, or each one assessed in a deviation
4.8 Record operating system and version of each server Recorded
4.9 Record the reverse proxy or load balancer product and version, if any Recorded

5. Environment variables

Record the value for non-secret variables; for secrets record "set" and the secret store. See the functional specification, section 9.1, for meanings.

No. Variable Required value or rule Actual (value, or "set") P/F Initials, date
5.1 COUNTERSIGN_STORAGE postgres (or the adapter named in the validation plan; never memory, never unset)
5.2 COUNTERSIGN_DATABASE_URL Secret. Set; names the application role, not the owner or a superuser; TLS to the database as your policy requires
5.3 COUNTERSIGN_SKIP_MIGRATE 1 when the application role has no DDL rights (recommended, see section 6)
5.4 COUNTERSIGN_AUTH entra or oidc (or proxy if approved in the plan); never demo
5.5 COUNTERSIGN_ENTRA_TENANT_ID, COUNTERSIGN_ENTRA_CLIENT_ID (entra) or COUNTERSIGN_OIDC_ISSUER, COUNTERSIGN_OIDC_CLIENT_ID (oidc) Set to the registered application
5.6 COUNTERSIGN_ENTRA_CLIENT_SECRET or COUNTERSIGN_OIDC_CLIENT_SECRET Secret. Set (required for entra); expiry date of the secret recorded in your calendar
5.7 COUNTERSIGN_ID_CLAIM Unset (default oid for entra, sub for oidc) or the provider's immutable unique identifier
5.8 COUNTERSIGN_NAME_CLAIM Unset (default name) or a claim holding the person's full name
5.9 COUNTERSIGN_ROLE_CLAIM Unset (default roles for entra, groups for oidc) or the claim your provider uses
5.10 COUNTERSIGN_ROLE_MAP The approved map, as JSON; attach it. Every target is a role id of the configuration
5.11 COUNTERSIGN_SESSION_SECRET Secret. Set; at least 32 characters, randomly generated; identical on all servers
5.12 COUNTERSIGN_SESSION_HOURS The value in your plan (default 8)
5.13 COUNTERSIGN_SIGN_MAX_AGE_MINUTES The value in your plan (default 15); not 0
5.14 COUNTERSIGN_PUBLIC_URL The https address people use, without a trailing slash
5.15 COUNTERSIGN_CONFIG Unset, or the path of the approved configuration file (it is applied only to an empty store)
5.16 COUNTERSIGN_DEMO_DATA Unset
5.17 COUNTERSIGN_PROXY_SECRET and the three COUNTERSIGN_PROXY_*_HEADER variables (proxy sign-in only) Secret set, at least 16 characters; header names as the proxy sends them
5.18 Where the variables are defined (service unit, container definition, secret store) and who can read them Recorded; readable only by the service account and administrators

6. Database

Recommended arrangement: an owner role that owns the table, functions, triggers and view and is used only by the database administrator; an application role that can only read and append. The authoritative commands are in Storage; in summary, with example role names:

-- As a database administrator, once (passwords set separately, never recorded here):
CREATE ROLE countersign_owner LOGIN;
CREATE ROLE countersign_app   LOGIN;
CREATE DATABASE countersign OWNER countersign_owner;
GRANT CONNECT ON DATABASE countersign TO countersign_app;

-- As countersign_owner, in that database, apply the reviewed schema:
--   psql "<owner connection>" -v ON_ERROR_STOP=1 -f db/postgres.sql
GRANT USAGE ON SCHEMA public TO countersign_app;
GRANT SELECT, INSERT ON countersign_entries TO countersign_app;
GRANT SELECT ON countersign_audit_trail TO countersign_app;

With this arrangement start the server and the command-line tool with COUNTERSIGN_SKIP_MIGRATE=1; otherwise they try to apply the schema themselves on start and fail for lack of rights.

No. Instruction Expected result Actual result P/F Initials, date
6.1 Confirm the table: SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'countersign_entries' ORDER BY ordinal_position; Ten rows: seq bigint, at timestamp with time zone, actor text, action text, record_id text, record_type text, prev_hash character, hash character, entry text, inserted_at timestamp with time zone
6.2 Confirm the triggers in the catalogue: SELECT tgname, tgenabled, pg_get_triggerdef(oid) FROM pg_trigger WHERE tgrelid = 'countersign_entries'::regclass AND NOT tgisinternal ORDER BY tgname; Three rows, each with tgenabled = O: countersign_entries_chain (BEFORE INSERT, FOR EACH ROW, function countersign_entries_chain), countersign_entries_no_change (BEFORE DELETE OR UPDATE, FOR EACH ROW, function countersign_entries_append_only), countersign_entries_no_truncate (BEFORE TRUNCATE, FOR EACH STATEMENT, function countersign_entries_append_only)
6.3 Cross-check in the information schema: SELECT trigger_name, event_manipulation, action_timing, action_orientation FROM information_schema.triggers WHERE event_object_table = 'countersign_entries' ORDER BY 1, 2; Three rows: countersign_entries_chain INSERT BEFORE ROW; countersign_entries_no_change DELETE BEFORE ROW; countersign_entries_no_change UPDATE BEFORE ROW. (The TRUNCATE trigger is not listed in this view; step 6.2 covers it)
6.4 Confirm the trigger functions match the reviewed file: SELECT proname, md5(prosrc) FROM pg_proc WHERE proname IN ('countersign_entries_chain', 'countersign_entries_append_only'); and compare prosrc with db/postgres.sql by reading Two rows; the function bodies are those of the file
6.5 Confirm constraints: SELECT conname, contype, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'countersign_entries'::regclass; A primary key on seq, a unique constraint on hash, a check seq > 0
6.6 Confirm the view: SELECT viewname, viewowner FROM pg_views WHERE viewname = 'countersign_audit_trail'; and read its definition with SELECT pg_get_viewdef('countersign_audit_trail'::regclass, true); One row, owned by the owner role; every column except inserted_at is taken from entry (as in db/postgres.sql)
6.7 Confirm ownership: SELECT tableowner FROM pg_tables WHERE tablename = 'countersign_entries'; The owner role, not the application role
6.8 Confirm the application role's attributes: SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = 'countersign_app'; All five flags false
6.9 Confirm the application role's table privileges: SELECT privilege_type FROM information_schema.table_privileges WHERE table_name = 'countersign_entries' AND grantee = 'countersign_app' ORDER BY 1; Exactly two rows: INSERT, SELECT
6.10 Confirm what it cannot do: SELECT p, has_table_privilege('countersign_app', 'countersign_entries', p) FROM unnest(ARRAY['UPDATE','DELETE','TRUNCATE','TRIGGER','REFERENCES']) AS p; Five rows, all false
6.11 Confirm it is not a member of a privileged role: SELECT r.rolname FROM pg_auth_members m JOIN pg_roles r ON r.oid = m.roleid WHERE m.member = 'countersign_app'::regrole; No row (or only roles without rights on the table; list them)
6.12 List every role with more than SELECT and INSERT on the table: SELECT grantee, privilege_type FROM information_schema.table_privileges WHERE table_name = 'countersign_entries' AND privilege_type NOT IN ('SELECT','INSERT'); and list superusers: SELECT rolname FROM pg_roles WHERE rolsuper; Only the owner role and the named administrators; record the names
6.13 Confirm durability settings: SHOW fsync; SHOW synchronous_commit; SHOW full_page_writes; on, on (or stricter), on
6.14 Confirm the database server's time zone handling and clock: SELECT now(), current_setting('TimeZone'); compared with a reference clock Within your tolerance (for example 1 second) of the reference
6.15 Confirm database audit logging of DDL and role changes, if your policy requires it: SHOW log_statement; or the audit extension's settings ddl or stricter, or the extension is configured

SQLite instead of PostgreSQL: replace 6.1 to 6.12 by sqlite3 <file> "SELECT name, sql FROM sqlite_master WHERE type = 'trigger';" (expected: countersign_entries_chain, countersign_entries_no_update, countersign_entries_no_delete) and by a record of the file's owner and permissions (readable and writable by the service account only).

7. Initialisation and application

No. Instruction Expected result Actual result P/F Initials, date
7.1 On a new installation: countersign init --config <approved configuration file> "Initialised the postgres store with 1 entry from <file>. Head #1 <hash>" (on an existing installation: "The store already holds <n> entries; nothing to do.")
7.2 countersign status Lines store postgres, entries <n>, head hash <64 hex>, configuration <id> "<name>", revision <r>, SHA-256 <64 hex>, records …, integrity ok; exit code 0
7.3 Compare the configuration SHA-256 from 7.2 with the one in the configuration's approval (3.2) Identical
7.4 countersign verify hash chain intact, replay all <n> entries reproduce, head hash <same as 7.2>; exit code 0
7.5 Start the application service on every server; check the service log Started without error
7.6 From the monitoring host: curl -s -o health.json -w '%{http_code}\n' <public URL>/api/health and attach health.json HTTP 200; "status":"ok", "mode":"server", "version":"1.0.0", "storage":"postgres", "auth": the approved method, "entries" and "headHash" equal to 7.2, "config" with the id, revision and hash of 7.2, "fault":null
7.7 Repeat 7.6 against each application server directly, if there are several Same headHash on all
7.8 Negative check of the settings guard, test environment only: start one instance with COUNTERSIGN_SESSION_SECRET unset and request /api/health HTTP 503; "status":"misconfigured"; problems contains "COUNTERSIGN_SESSION_SECRET must be at least 32 characters (for example: openssl rand -base64 48)." Restore the setting afterwards

8. Network, TLS and time

No. Instruction Expected result Actual result P/F Initials, date
8.1 Open <public URL>/app in a browser and inspect the certificate Valid certificate for the host name, issued by a trusted authority, not expired; protocol TLS 1.2 or newer
8.2 Request the http address: curl -sI http://<host>/app Redirect to https, or connection refused
8.3 After signing in (section 9), inspect the cookie countersign_session in the browser's developer tools Flags HttpOnly, Secure, SameSite=Lax
8.4 Confirm that the application port is reachable only from the reverse proxy or load balancer (firewall rule or a connection attempt from a workstation) Not reachable directly
8.5 Confirm that the database port is reachable only from the application servers and administration hosts As specified
8.6 On every application server show time synchronisation: timedatectl status or chronyc tracking Synchronised to the organisation's time source; offset within tolerance
8.7 Confirm monitoring of time synchronisation Alert defined
8.8 Proxy sign-in only: send a request to the application through the proxy with a forged X-Forwarded-User header and with a forged X-Countersign-Proxy-Secret header from a client The proxy removes or overwrites both; the application shows the real identity or answers 401

9. Sign-in and roles

Use real accounts of the identity provider. Record account names, never passwords.

No. Instruction Expected result Actual result P/F Initials, date
9.1 Without a session: curl -s -o /dev/null -w '%{http_code}\n' <public URL>/api/entries 401
9.2 Open <public URL>/app in a fresh browser session and sign in as test user 1 Redirect to the identity provider, sign-in with the provider's rules (including multi-factor if required), return to the application; the application shows the user's full name and role(s)
9.3 Request <public URL>/api/bootstrap in the signed-in browser "user" shows the expected id (the provider's immutable identifier), name and roles (role ids of the configuration); "auth" is the approved method; "signMaxAgeMinutes" is the planned value
9.4 Decode the ID token or consult the provider's token configuration The token contains the role claim and auth_time. If auth_time is absent, raise a deviation: the application then uses the token's issue time for the recent-sign-in rule (risk assessment, R-12)
9.5 Sign in as test user 2 with a different role The expected role(s)
9.6 Sign in as a user who is assigned to the application but holds no mapped role Signed in; can read; every write action is unavailable
9.7 Try to sign in as a user who is not assigned to the application in the provider Refused by the provider (if assignment is required in your design)
9.8 Review the role map against the configuration: every role id of the configuration that is used has at least one provider group; no provider value that could reach the application equals a role id unintentionally Confirmed; attach the list of groups and members at the time of execution
9.9 Sign out The application returns to its signed-out state; /api/entries answers 401 in that browser
9.10 Confirm segregation: no database or server administrator holds an author or approve role Confirmed against the lists of 6.12 and 9.8

10. Backup, monitoring and documents

No. Instruction Expected result Actual result P/F Initials, date
10.1 Show the backup configuration of the database (schedule, retention, location, point-in-time recovery) As specified in the backup SOP
10.2 Show the last successful backup Present and within schedule
10.3 Restore the latest backup into a scratch database and run countersign verify against it (environment variables pointing at the scratch database) hash chain intact, replay all <n> entries reproduce; head hash equals that of the source at the time of the backup
10.4 Show the monitoring check on /api/health and its alert Alerts on any HTTP status other than 200 and on status other than ok; recipients named
10.5 Show the schedule for countersign verify (for example a daily job whose non-zero exit code raises an alert) Defined
10.6 Record the first head-hash anchor: number of entries and head hash from 7.2, written to the anchor register outside the database administrators' reach Anchor recorded; reference: ______
10.7 Confirm that the SOPs listed in the validation plan are effective and that operators are trained Confirmed
10.8 Confirm that web-server access logs are kept for the period in your policy and include the status code of requests to /api/act Confirmed

11. Deviations

No. Step Description Classification Resolution Closed (initials, date)

12. Attachments

No. Attachment
1 npm-ls.txt, sbom.json, npm audit output
2 Output of the SQL queries of section 6
3 health.json, output of countersign status and countersign verify
4 Role map, list of provider groups and members
5 Screenshots of sign-in and cookie flags

13. Conclusion

Statement Yes / No
All steps passed or deviations are closed
The installation is released for operational qualification (test) / for use (production, together with the summary report)
Role Name Signature Date
Executed by
Reviewed by (system owner)
Approved by (QA)