GitHat Compliance Documentation: SCIM Provisioning, GDPR Erasure, Audit Trail, and Data Encryption
Developer-facing compliance reference covering SCIM 2.0 provisioning, GDPR Article 17 two-phase org erasure, audit logging, and DynamoDB SSE-at-rest encryption as verified in source code.
SCIM 2.0 Provisioning
GitHat implements RFC 7644 (SCIM 2.0) for automated user and group provisioning from Okta, Azure AD, OneLogin, and compatible identity providers. A single Lambda (ScimRouterFunction) handles all /scim/v2/* paths, with a Lambda timeout of 10 seconds and 512 MB memory.
Authentication uses a per-org bearer token stored as a bcrypt hash (scim_token_hash) on the organization row, alongside a boolean flag scim_token_enabled. The bearer token is extracted from the Authorization header. Discovery endpoints (GET /scim/v2/ServiceProviderConfig, GET /scim/v2/Schemas, GET /scim/v2/ResourceTypes) are unauthenticated. All user and group endpoints require the bearer token and return 401 if it is missing, empty, or does not match any enabled org. Responses use Content-Type: application/scim+json.
The ServiceProviderConfig declares: patch supported, bulk not supported, filter not supported (maxResults 200 for pagination only), changePassword not supported, sort not supported, etag not supported.
Unauthenticated discovery endpoints:
GET /scim/v2/ServiceProviderConfig
GET /scim/v2/Schemas
GET /scim/v2/ResourceTypes
Authenticated user endpoints:
GET /scim/v2/Users — list users in the calling org
POST /scim/v2/Users — provision a user
GET /scim/v2/Users/{id} — fetch a single user
PATCH /scim/v2/Users/{id} — update active / displayName / name.formatted
PUT /scim/v2/Users/{id} — same handler as PATCH
DELETE /scim/v2/Users/{id} — deactivate (not hard-delete)
Authenticated group endpoints:
GET /scim/v2/Groups — list groups (one group per org)
GET /scim/v2/Groups/{id} — fetch a single group
Authorization: Bearer <per-org-scim-token>User Provisioning (POST /scim/v2/Users)
Provisioning is idempotent. If a user with the given email (drawn from userName or emails[0].value, lowercased and trimmed) already exists in GitHat-Users, the handler reuses that row and creates or updates the org membership rather than returning a 409. This avoids confusing Okta and Azure AD reconcile loops that re-issue the same POST when a user moves between groups.
If the user is new, a UUID is assigned and the user is created with emailVerified: true and authProvider: 'scim', meaning SCIM-provisioned identities skip the email-verification step on the assumption that the IdP has already verified them.
The org membership row is written to GitHat-OrgMembers with role: 'member' and provisionedBy: 'scim'. If the membership already exists and active is false, a POST with active: true re-activates it (supporting IdPs that deactivate via DELETE then re-create). The SCIM resource returned on success uses HTTP 201.
User Updates (PATCH /scim/v2/Users/{id}) and Deactivation (DELETE /scim/v2/Users/{id})
PATCH supports the replace and add operations. Recognized paths: active (boolean, written to the OrgMembers row), displayName (string, written as name on the Users row), and name.formatted (string, also written as name on the Users row). A bare replace operation with an object value may set active, displayName, and name.formatted in one call. All other paths are silently ignored — this is intentional to tolerate Okta's habit of sending replace ops for meta.lastModified.
DELETE /scim/v2/Users/{id} does not hard-delete the GitHat user row. It sets active: false on the OrgMembers row. The comment in users-delete.js explains why: a hard delete would remove artifacts the user owns across the platform (orgs, agents, MCP servers, billing). The response is 204 No Content.
GET /scim/v2/Users/{id} enforces org isolation: a user is only returned when they are a member of the calling org. A user who exists in GitHat-Users but belongs to a different org returns 404.
User List Pagination (GET /scim/v2/Users)
Pagination follows RFC 7644 §3.4.2: startIndex is 1-based (default 1), count defaults to 100, maximum 200. The handler fetches all org member IDs from GitHat-OrgMembers, slices the page in memory, then batch-fetches the corresponding user rows from GitHat-Users. The comment in users-list.js notes this is suitable while orgs have thousands of members; a cursor-based variant is the stated upgrade path beyond that.
Groups (GET /scim/v2/Groups and GET /scim/v2/Groups/{id})
GitHat exposes one SCIM Group per org. The group's id is the org's own id, and its displayName is org.name or org.slug. Members are listed with their userId as value, a $ref URL pointing to /scim/v2/Users/{userId}, and the member's email or name as display. GET /scim/v2/Groups/{id} returns 404 if the requested groupId does not equal the calling org's id — cross-org group reads are not permitted. Multi-group-per-org (teams) is noted in the source as a follow-up.
GDPR Article 17 Erasure — Two-Phase Org Deletion
Org deletion is implemented as a two-phase process explicitly labeled GDPR Article 17 in both source files.
Phase 1 is triggered synchronously by DELETE /org/{id}, restricted to the org owner (not admins). A rate limit of 3 requests per hour per user applies. The handler: (1) stamps status = 'deleting', deletedAt = now, and purgeAfter = now + 30 days on the org row; (2) increments tokenVersion on every org member, invalidating any in-flight JWTs for that org; (3) marks every app under the org with active: false, webhooksSuspended: true, scimTokenSuspended: true, and suspendedReason: 'org_erasure_pending'; (4) writes an immutable audit row under pk=ORG#{orgId} with action 'org.erasure_requested', recording gdpr_article: 17, deletedAt, purgeAfter, members_session_bumped, and apps_suspended. The response includes a message instructing the owner to contact support@githat.io within the 30-day window to undo.
A second DELETE call while status is already 'deleting' returns 200 with alreadyScheduled: true and does not re-run phase 1, preventing double tokenVersion bumps and duplicate webhook fanouts.
GDPR Article 17 Erasure — Phase 2 Hard Purge
Phase 2 is a scheduled Lambda (PurgeDeletedOrgsFunction) triggered by a CloudWatch Events rule (PurgeDeletedOrgsHourly) on a rate(1 hour) schedule. It has a 300-second timeout. The handler scans GitHat-Organizations for rows where status = 'deleting' AND purgeAfter < now(). Before purging each match it re-reads the org row to guard against a race where an admin cleared the deleting status between the scan and the purge; it also re-checks that purgeAfter has elapsed.
The cascade purge order for each org is: (1) all apps and their API keys (deleteAllApiKeys then deleteApp for each app, paginated); (2) GitHub installations; (3) skill installations (listInstallationsByOrg returns an installations array, not items); (4) org member rows (removeOrgMember for each member); (5) the org row itself (deleteOrganization). Each category is wrapped in a try/catch so a failure in one category does not block the rest. The handler is idempotent: if an org row is already gone, the loop continues.
After a successful purge, the handler writes an audit event with action 'org.erasure_purged' and metadata: gdpr_article: 17, phase: 'hard_purge', deletedAt, purgeAfter. Audit-log rows under pk=ORG#{orgId} are explicitly not deleted in phase 2 — the source comment states that the deletion event must outlive the org row for diligence and forensic reconstruction. The handler returns a summary object: { scanned, purged, failed, errors }.
Audit Trail
Audit events are written to the DynamoDB table GitHat-AuditLog (configurable via the AUDIT_LOG_TABLE env var, defaulting to 'GitHat-AuditLog').
writeAuditEvent is the primary write path. Each row has: pk = '{actor.type}#{actor.id}' (e.g. USER#uuid, ORG#id, APP#id), sk = 'EVENT#{ISO8601}#{ulid}', plus event_type, actor_type, actor_id, action, target_type, target_id, metadata, created_at, and ttl_at. A GSI named EventTypeIndex indexes on event_type (hash) + pk (range) for platform-wide queries such as fetching all key.rotated events. Metadata is capped at 4,096 bytes; if the serialized JSON exceeds that, it is replaced with { _truncated: true, _reason: 'metadata exceeded 4KB cap' }.
Audit failures never throw — writeAuditEvent catches all errors, logs to console.error, and returns null, so the user-facing operation that triggered the audit write is never blocked.
The TTL on audit rows is set to 2,557 days (approximately 7 years) from the time of the event. The source comment explains this was raised from a prior 365-day value, citing SOC 2, HIPAA, and PCI-grade expectations for an identity provider holding payment-adjacent data.
recordAuditEvent is a convenience wrapper for auth handlers. It calls writeAuditEvent and additionally fans out webhook events to app subscribers (fire-and-forget) and broadcasts a realtime WebSocket event when an appId is provided. It also maps underscore-style event types (login_success) to dot-style webhook event names (login.success).
logAuditEvent is a lower-level function used by some handlers to write log entries co-located with a resource row in a different table, using sk = 'LOG#{ISO8601}#{uuid}'. Its TTL is controlled by the AUDIT_LOG_TTL_DAYS env var, defaulting to 90 days.
The IP address recorded by recordAuditEvent uses getClientIp() from response.js, which prefers the API Gateway sourceIp (TCP source, not forgeable) over X-Forwarded-For headers.
// writeAuditEvent — primary write path
await writeAuditEvent({
actor: { type: 'USER', id: userId }, // or 'AGENT', 'APP'
action: 'org.erasure_requested',
targetType: 'ORG',
targetId: orgId,
metadata: { gdpr_article: 17, purgeAfter: '...' },
});
// pk format: 'USER#<uuid>' | 'ORG#<id>' | 'APP#<id>'
// sk format: 'EVENT#<ISO8601>#<ulid>'Audit Log Archival (S3 Object Lock)
The GitHat-AuditLog table has a DynamoDB Stream with StreamViewType: NEW_IMAGE. A separate Lambda (AuditArchiverFunction) consumes the stream with BatchSize 100 and MaximumRetryAttempts 3. It writes every INSERT and MODIFY event to an S3 bucket named githat-audit-archive-{AWS::AccountId}. The template comment states the bucket uses S3 Object Lock in COMPLIANCE mode with 7-year retention. The bucket itself is admin-managed (not created by the CloudFormation template); the template only references it by name. The AuditArchiverFunction has a dedicated IAM role (AuditArchiverRole) separate from the main execution role, providing a tamper-evident mirror of the live DynamoDB table.
Data Encryption at Rest (SSE)
Every DynamoDB table in template.yaml is created with SSESpecification: SSEEnabled: true. This applies to: GitHat-Users, GitHat-Sessions, GitHat-Organizations, GitHat-OrgMembers, GitHat-VerifyTokens, GitHat-OauthCodes, and GitHat-AuditLog, along with all other tables defined in the template. SSEEnabled: true enables AWS-managed encryption using the AWS-owned DynamoDB key (AES-256, managed by DynamoDB). The template does not specify a KMS CMK for these tables; the encryption type is the DynamoDB default (AES256, not KMS).
All tables with user or org data also have PointInTimeRecoveryEnabled: true and DeletionProtectionEnabled: true.
JWT signing uses a KMS-backed RS256 key (the GITHAT_RS256_KMS_KEY_ID env var). The template comment states that the private key bytes never leave the KMS HSM.