ADR 008: GRC integrations and automated tests
Status: Accepted
Context
ADR 007 left every control at Not started
because nothing feeds it signals yet. The first real signal source is Google
Workspace: today's Risk Assessment page fetches Cloud Identity policies and the
browser decides pass or fail. Nothing is saved, there is no history, and the
login is a per-user UserToken stored in plain text, so the check stops working
when that person leaves.
We want saved, scheduled tests whose latest results move controls to Passing or Failing on their own. That needs a few decisions up front, because every issue in the epic (connect, evaluate, run, signal, read, UI) builds on them:
- Whose credentials are they, and how are they stored?
- What can a test result be, and what does a control do with each outcome?
- What does "failing since Oct 3" mean when a run errors in between?
- How much do we store per run?
Decision
1. The model
TestDefinition ("2-step verification enforced") ← catalog, seeded by us, no teamId
└─ TestDefinitionControl ──► ControlTemplate ("mfa-enforced")
Integration (team's Google Workspace) ← teamId, encrypted credentials, status
└─ TestRun (one per run: nightly or "Run now")
└─ TestResult (one per test: PASS / FAIL / EXCUSED / ERROR)
TestException (team excuses a test or org unit) ← teamId, reason, approver, expiry
Latest results → a ControlSignalSource → ControlStatusService.recalculate
→ the control turns green or red. The source plugs into the existing
CONTROL_SIGNAL_SOURCES (ADR 007 §5); the status
rules do not change.
A test links to the control templates it is evidence for. A team's control (a copy of a template) picks up the tests of its template. Custom controls have no template and so no tests.
2. Integrations belong to the team
An Integration is owned by the team, with one row per provider
(@@unique([teamId, provider])). The user who connected it is stored as
connectedById for the audit trail only: nothing is authorised through
that user, and deleting them sets the column to null and leaves the
integration running.
- v1 uses a Google OAuth refresh token, kept on the integration. It replaces
the per-user
UserTokenfor tests. - A refresh token is still granted by a Google account. If that Google
account is suspended or deleted, Google rejects the token and the integration
moves to
NEEDS_REAUTH. Losing the RiskFlow user does not do this; losing the Google admin who connected it can. Admins should connect with an account that is meant to stay. - Later option: a service account with domain-wide delegation removes the
dependency on one human's Google account, at the cost of a one-time setup in
the customer's Admin console. We do not build it now. We revisit it if
teams hit
NEEDS_REAUTHafter staff changes. - The scopes stay read-only: the same two as the Risk Assessment login today (Cloud Identity policies and Directory org units).
Statuses: CONNECTED, NEEDS_REAUTH (credentials rejected; an admin must
reconnect), ERROR (the last run failed for another reason), DISCONNECTED.
3. Credentials are encrypted at rest
credentialsEnc holds the credentials encrypted with AES-256-GCM using
INTEGRATION_ENCRYPTION_KEY, a 32-byte key from the environment, never from
the database.
- The stored value is
v1:<iv>:<tag>:<ciphertext>(base64 parts, a fresh random 12-byte IV per write, JSON credentials as the plaintext). Thev1prefix lets a future key rotation tell which envelope wrote a row. Decided and built in #43. - The API refuses to boot in production without a valid key, so a missing key is found at deploy, not the first time an admin clicks Connect.
- Only the integration service and the test runner decrypt. Credentials are never returned by an endpoint and never logged.
- Disconnecting sets
credentialsEncto null and the status toDISCONNECTED. The runs and results stay as history.
4. Result statuses and what a control does with each
| Result | Meaning | Signal sent to the control |
|---|---|---|
PASS | The live setting matches what the test expects | PASS |
FAIL | It does not, for at least one org unit not covered by an exception | FAIL |
EXCUSED | It does not, but an unexpired exception covers every failing org unit (or the whole test) | PASS |
ERROR | We could not tell (API error, permission missing, credentials rejected) | none |
ERRORand stale results send no signal, so a broken connection never turns a control green or red. A result is stale when it is older than 48 hours or its integration is notCONNECTED. A control with no signals shows Not started, which is honest: nothing is currently checking it. The constant lives with the signal source (#46).EXCUSEDcounts as a pass for the control. The exception is the team's recorded decision with a reason, an approver and an expiry, and it appears in the audit log. The Tests page still shows the test as excused, not passing.- Org-unit exceptions excuse only the org units they name. If some failing
org units are covered and others are not, the result is
FAIL.failingOrgUnitskeeps every failing org unit and marks the covered onesexcused: true; the API shows the two groups separately. Keeping them lets adding or removing an exception decide the latest result again from the saved row, without calling Google (#47). - A control with several tests follows the existing rule: any
FAILmakes it Failing; otherwise it is Passing once every test that does send a signal passes. - When controls are recalculated (#46): after every run, failed ones
included, for all of the team's controls linked to a Google test, and for
all of the team's controls after a disconnect. Not only the controls whose
test changed: a result that was stale and is fresh again changes the control
too, and a failed run can leave the integration
ERRORorNEEDS_REAUTH, which takes the signals away. So a single failed nightly run sends linked controls back to Not started until the next good run. Nothing recalculates on read, and no sweep is needed for the 48-hour rule: aCONNECTEDteam is run (and recalculated) every 24 hours.
5. "Failing since" is firstFailedAt
TestResult.firstFailedAt is when the test started failing, carried forward
from the test's previous result:
FAIL: the previous result'sfirstFailedAtif it has one, otherwise the run's start time.ERROR: the previous result'sfirstFailedAt, if any. We could not tell, so one failed API call does not reset the clock.PASSorEXCUSED: null. When an exception expires and the test fails again, the clock starts again.
6. One result per test per run
A run stores one TestResult per test, not one per org unit. Which org
units fail is stored as JSON on that row (failingOrgUnits), along with the
actual value that was compared. This keeps a run to about 48 rows per team,
and the Tests page reads one row per test. We lose the ability to query "all
results for org unit X" in SQL; nothing planned needs it.
TestResult repeats teamId from its run, as ControlRequirement does, so
tenant-scoped reads do not need a join. An index on
(teamId, testDefinitionId, createdAt DESC) serves "latest result per test per
team".
7. Catalog rows and team rows are split
| Tables | teamId | Written by | |
|---|---|---|---|
| Catalog | TestDefinition, TestDefinitionControl | none | idempotent seed scripts only |
| Team | Integration, TestRun, TestResult, TestException | always | the integration service, the runner and the API |
Team rows follow the usual rules: every query filters by teamId taken from
the principal, never the body, and a miss across teams returns 404
(ADR 004). Seeds are keyed on TestDefinition.key.
A test that is retired is set isActive = false, not deleted, so old results
keep their definition.
8. Permissions and audit
| Action | Permission |
|---|---|
| Connect, disconnect, Run now | MANAGE_INTEGRATIONS (new, 1 << 25): ORG_ADMIN and MSP_SUPER_ADMIN |
| Read integrations, runs, results | ACCESS_CONTROLS |
| Add or remove a test exception | MANAGE_CONTROLS |
As with MANAGE_CONTROLS, "manage implies access" lives in the role presets,
and a spec checks every preset holding MANAGE_INTEGRATIONS also holds
ACCESS_CONTROLS. A data migration ORs the new bit into existing admin rows.
Audit events (category COMPLIANCE):
| Action | Actor |
|---|---|
compliance.integration_connected | user |
compliance.integration_disconnected | user |
compliance.integration_needs_reauth | system |
compliance.test_run_started | user for Run now, system for the nightly run |
compliance.test_status_changed | system (a test flipped between PASS and FAIL) |
compliance.test_exception_added | user |
compliance.test_exception_removed | user |
9. How a Google test decides PASS or FAIL
Decided and built in #44. The tests live in
apps/backend/prisma/catalog/google-workspace-tests.ts;
evaluateGoogleTest (modules/grc-tests/google/) is a pure function the
runner calls with the fetched policies and org units.
TestDefinition.expectedis a list of rules. Each rule is{ field, op, value }withopone ofeq(by value, key order ignored),in,gte,lte,durationLte,durationGte("43200s"compared as seconds) ornotAfter(a timestamp at or before a date or"now"). Rules are written so a setting at least as strict as best practice passes. Fields the rules don't name are ignored.- Each org unit gets its effective setting: of the policies on it or an
ancestor, the one with the highest
sortOrder(without one, the nearer org unit, thenADMINoverSYSTEM). Group-targeted policies are ignored. The test passes only when every org unit passes;failingOrgUnitslists the ones that don't, each with its effective values. - A field nobody set takes Google's documented default from
TestDefinition.defaults(its own nullable column). With no default it fails as "not configured" (notConfiguredon the org unit). - An unknown
op, a malformed rule or a value of the wrong type givesERROR, neverPASS. - Five checks are stricter than the old browser check, because its best practice was the weakest option or not a value Google accepts: Advanced Protection security codes, 2-step verification methods, Meet's two join settings and Chat external file sharing. Teams that need the looser setting (for example patient video visits) record a test exception.
10. Running the tests
Decided and built in #45. TestRunService (modules/grc-tests/) runs a
team's tests on "Run now" (POST /tests/runs) and from Vercel Cron
(GET /internal/grc-tests, every 15 minutes).
- One run at a time per team. Starting a run takes a Postgres advisory
lock for the team, then returns the run already
RUNNINGif there is one. "Run now" and the cron can arrive together without starting two runs. - "Run now" is limited to once per team every 5 minutes, counted from
saved
MANUALruns, so it holds across serverless instances (429,TEST_RUN_RATE_LIMITED). The nightly run is not limited. - A failed run writes no results. Google errors and rejected credentials
mark the run
FAILEDwith anerrorCode, so the previous run's results stay the latest. The integration moves toERRORwith that code, unless the token service already setNEEDS_REAUTH. The next successful run movesERRORback toCONNECTED. - The cron runs
CONNECTEDandERRORintegrations whoselastRunAtis more than 24 hours old, oldest first, up to 20 per call, and starts no new run after 240 seconds (Vercel stops a function at 300).ERRORis included because it means "the last run failed", often for a passing reason; leaving it out would stop nightly runs after one Google outage.NEEDS_REAUTHandDISCONNECTEDneed a person and are skipped. lastRunAtis set by failed runs too, so a failing team waits a day like any other instead of being retried, first in line, every 15 minutes.- A run still
RUNNINGafter 10 minutes is markedFAILEDwithTEST_RUN_TIMED_OUTat the start of each cron call, and before a team's next run. Each Google call times out after 30 seconds. - Status changes are audited against the test's result in the team's last
completed run, including changes to and from
ERROR. A test's first result is not a change.
Consequences
- Until a team connects Google, its controls stay Not started, as today.
- A broken or revoked connection shows up as
NEEDS_REAUTHand as controls drifting back to Not started once results go stale. It never shows up as false Failing or false Passing. - A control with one passing test and one test that is erroring shows Passing, because the erroring test sends no signal. The Tests page shows the error; the control page should not hide it.
- Storing every test result every night is about 48 × 365 ≈ 17,500 rows per team
per year. That is fine for now; pruning old runs is a later decision and must
keep each test's latest result and the run that set its
firstFailedAt. - A refresh token depends on one Google account staying active (§2). The service-account option is the way out if that proves painful.
- Audit events need wording in the frontend
audit-events.ts. Until then they show through its generic fallback. Each emitting issue (#43, #45, #47) adds its own wording andmetadatashape. - Adding Microsoft 365 or MDM later means new
IntegrationProvidervalues, new test definitions and a new signal source. The tables here do not change.
References
- Epic: GitHub issue #41 (GRC integrations & automated tests); this ADR is issue #42
apps/backend/prisma/schema/grc-tests.prisma- ADR 004 — 404 over 403
- ADR 007 — GRC control model
- Glossary: Integration, Test definition, Test run, Test result, Test exception
in
CONTEXT.md