Design the audit log behind a healthcare API: log every read of patient data in the same transaction that returns it, include denied and break-glass access, make the table append-only in Postgres, answer access-history questions and purge only after the retention period, with the data in your own AWS account.

When a patient asks who looked at their chart, or a privacy officer investigates a staff member who opened a celebrity's record, the answer comes from the access log. A log that only records changes cannot answer either question, since viewing a record changes nothing. A log written after the response is sent can miss the one access that matters, if the write fails after the data has already left the API.
This guide designs the access audit log for a healthcare API: one row for every view of patient data, denied and emergency access included, written in the same transaction as the read, protected against updates in Postgres and queryable by patient. It starts with what the log has to record and how to build it by hand on AWS, then builds it in Go with Encore so the database stays in your own AWS account.
The HIPAA Security Rule's audit controls standard, 45 CFR 164.312(b), asks for mechanisms that record and examine activity in systems that contain or use electronic PHI, and does not list fields. The HHS audit controls newsletter says the Security Rule does not identify what an audit log should collect or how often it should be reviewed, and describes application audit trails as covering the creating, reading, editing and deleting of records with ePHI. For a patient-records API, each row needs:
Keep the PHI itself out of the log, so the row says who opened patient 1042, not what the record contained.
phi_access_log. Putting the log in the same database lets the read and the log row commit together.Encore turns infrastructure declared in Go or TypeScript code into resources in your own cloud account. The records database and the retention schedule are declared in the records service, and deploying creates the database on RDS in your own AWS account, encrypted at rest, as listed in the AWS infrastructure docs. The patient data and the access log stay in that account, while the BAA with AWS, access reviews and written policies stay with your team.

Run in your terminal to get started locally.
The migration creates the log and the triggers that keep it append-only. The PL/pgSQL trigger docs cover TG_OP and why a statement-level trigger is needed for TRUNCATE, which row triggers never see:
-- records/migrations/1_records.up.sql
CREATE TABLE patients (
id BIGSERIAL PRIMARY KEY,
full_name TEXT NOT NULL,
dob DATE NOT NULL
);
-- Which staff members treat which patients.
CREATE TABLE care_team (
patient_id BIGINT NOT NULL REFERENCES patients (id),
user_id TEXT NOT NULL,
PRIMARY KEY (patient_id, user_id)
);
CREATE TABLE phi_access_log (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_id TEXT NOT NULL,
actor_role TEXT NOT NULL,
patient_id BIGINT NOT NULL,
action TEXT NOT NULL, -- view_record, view_access_log, ...
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'break_glass')),
reason TEXT,
request_id TEXT NOT NULL
);
CREATE INDEX phi_access_log_patient ON phi_access_log (patient_id, at);
CREATE INDEX phi_access_log_actor ON phi_access_log (actor_id, at);
-- Append-only: no updates, no truncation, and deletes only from the
-- retention job, for rows past the retention period.
CREATE FUNCTION phi_access_log_guard() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE'
AND current_setting('audit.retention_purge', true) = 'on'
AND OLD.at < now() - interval '6 years' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'phi_access_log is append-only (%)', TG_OP;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER phi_access_log_no_change
BEFORE UPDATE OR DELETE ON phi_access_log
FOR EACH ROW EXECUTE FUNCTION phi_access_log_guard();
CREATE TRIGGER phi_access_log_no_truncate
BEFORE TRUNCATE ON phi_access_log
FOR EACH STATEMENT EXECUTE FUNCTION phi_access_log_guard();
-- The application role can insert and select, but cannot delete.
-- Purge runs as a dedicated role through a definer function, so
-- SET LOCAL from an application connection cannot authorize a delete.
CREATE ROLE phi_audit_purge NOLOGIN;
REVOKE DELETE, UPDATE, TRUNCATE ON phi_access_log FROM PUBLIC;
GRANT SELECT, INSERT ON phi_access_log TO CURRENT_USER;
GRANT DELETE ON phi_access_log TO phi_audit_purge;
CREATE FUNCTION purge_expired_phi_access_log() RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
n integer;
BEGIN
PERFORM set_config('audit.retention_purge', 'on', true);
DELETE FROM phi_access_log WHERE at < now() - interval '6 years';
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
ALTER FUNCTION purge_expired_phi_access_log() OWNER TO phi_audit_purge;
REVOKE ALL ON FUNCTION purge_expired_phi_access_log() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION purge_expired_phi_access_log() TO CURRENT_USER;
Tested against Postgres, UPDATE, DELETE and TRUNCATE on the log from the application role all fail with the trigger's error. Calling purge_expired_phi_access_log() removes a row from seven years ago, and the same call leaves recent rows in place even if an application connection has set the session flag.
Get decides access, writes the log row and reads the patient inside one transaction. If the insert fails, the handler returns an error before the SELECT on patients runs. Denied attempts are committed before the error is returned, so they stay in the log. The auth docs cover the auth handler that supplies the user ID and role:
// records/records.go
package records
import (
"context"
"errors"
"time"
"encore.app/auth"
"encore.dev"
encauth "encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("records", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
type GetParams struct {
// Set only for emergency access outside the care team.
BreakGlassReason string `query:"break_glass_reason" encore:"sensitive"`
}
type Patient struct {
ID int64 `json:"id"`
FullName string `json:"full_name" encore:"sensitive"`
DOB time.Time `json:"dob" encore:"sensitive"`
}
// Get returns a patient record and logs the access in the same
// transaction. If the log row cannot be written, no PHI is returned.
//
//encore:api auth method=GET path=/patients/:id
func Get(ctx context.Context, id int64, p *GetParams) (*Patient, error) {
actor, _ := encauth.UserID()
role := encauth.Data().(*auth.Data).Role
tx, err := db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
var onTeam bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM care_team WHERE patient_id = $1 AND user_id = $2)
`, id, string(actor)).Scan(&onTeam); err != nil {
return nil, err
}
outcome := "allowed"
switch {
case role != "clinician":
outcome = "denied"
case !onTeam && p.BreakGlassReason == "":
outcome = "denied"
case !onTeam:
outcome = "break_glass"
}
if err := logAccess(ctx, tx, string(actor), role, id, "view_record", outcome, p.BreakGlassReason); err != nil {
return nil, err
}
if outcome == "denied" {
// Commit so the denied attempt stays in the log.
if err := tx.Commit(); err != nil {
return nil, err
}
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "not on this patient's care team"}
}
var pt Patient
err = tx.QueryRow(ctx, `
SELECT id, full_name, dob FROM patients WHERE id = $1
`, id).Scan(&pt.ID, &pt.FullName, &pt.DOB)
if errors.Is(err, sqldb.ErrNoRows) {
if err := tx.Commit(); err != nil {
return nil, err
}
return nil, &errs.Error{Code: errs.NotFound, Message: "patient not found"}
} else if err != nil {
return nil, err
}
return &pt, tx.Commit()
}
func logAccess(ctx context.Context, tx *sqldb.Tx, actor, role string, patientID int64, action, outcome, reason string) error {
requestID := ""
if t := encore.CurrentRequest().Trace; t != nil {
requestID = t.TraceID
}
_, err := tx.Exec(ctx, `
INSERT INTO phi_access_log
(actor_id, actor_role, patient_id, action, outcome, reason, request_id)
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7)
`, actor, role, patientID, action, outcome, reason, requestID)
return err
}
The patient fields carry encore:"sensitive", which per the sensitive data docs redacts them from Encore's request traces, so names and birth dates do not appear in tracing while the request ID in the log still links to the trace. The tag is ignored in local development, so use synthetic patients under encore run.
Privacy officers query by patient, and reading the log is logged too. The retention job is the only code that sets the flag the trigger checks:
// records/accesslog.go
package records
import (
"context"
"time"
"encore.app/auth"
encauth "encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/cron"
)
type Entry struct {
At time.Time `json:"at"`
ActorID string `json:"actor_id"`
Action string `json:"action"`
Outcome string `json:"outcome"`
Reason *string `json:"reason" encore:"sensitive"`
}
type History struct {
Entries []Entry `json:"entries"`
}
// AccessHistory answers "who looked at this patient's record". Reading
// the log is itself logged.
//
//encore:api auth method=GET path=/patients/:id/access-log
func AccessHistory(ctx context.Context, id int64) (*History, error) {
actor, _ := encauth.UserID()
role := encauth.Data().(*auth.Data).Role
tx, err := db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
outcome := "allowed"
if role != "privacy_officer" {
outcome = "denied"
}
if err := logAccess(ctx, tx, string(actor), role, id, "view_access_log", outcome, ""); err != nil {
return nil, err
}
if outcome == "denied" {
if err := tx.Commit(); err != nil {
return nil, err
}
return nil, &errs.Error{Code: errs.PermissionDenied, Message: "privacy officers only"}
}
rows, err := tx.Query(ctx, `
SELECT at, actor_id, action, outcome, reason
FROM phi_access_log
WHERE patient_id = $1
ORDER BY at DESC
LIMIT 500
`, id)
if err != nil {
return nil, err
}
defer rows.Close()
h := &History{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.At, &e.ActorID, &e.Action, &e.Outcome, &e.Reason); err != nil {
return nil, err
}
h.Entries = append(h.Entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close()
return h, tx.Commit()
}
var _ = cron.NewJob("phi-access-log-retention", cron.JobConfig{
Title: "Delete access log rows past the retention period",
Every: 24 * cron.Hour,
Endpoint: PurgeExpired,
})
// PurgeExpired is the only application entry point that can delete rows.
// The definer function, not this connection, sets the session flag.
//
//encore:api private
func PurgeExpired(ctx context.Context) error {
_, err := db.Exec(ctx, `SELECT purge_expired_phi_access_log()`)
return err
}
The database docs cover migrations and transactions, and the cron job docs cover schedules. encore run starts Postgres locally, so Get with Authorization: Bearer dr-lee and AccessHistory with privacy-1 can be tried against seeded test patients. Cron jobs do not run locally or in preview environments, and in deployed environments the purge is Encore platform managed.
When a coding agent later adds a patient search endpoint, the review question is whether it calls logAccess for each patient it returns, and the trigger guarantees that whatever it logs cannot be edited afterwards by application code.
Best fit: Healthcare teams running a Go API in their own AWS or GCP account who want access logging, its retention job and PHI redaction in traces to live in the same service as the records.
audit.retention_purge itself. A database owner can still drop them, so restrict who holds those credentials and export the log regularly to storage the application cannot change.break_glass row needs a person to read it within days. Build the report before the feature ships.The audit controls standard in 45 CFR 164.312(b) requires mechanisms that record and examine activity in systems that contain or use electronic protected health information. HHS guidance notes that the Security Rule does not say which fields to collect or how often to review them, so those choices follow from your risk analysis. Regular review of activity records is a separate required procedure in 164.308(a)(1)(ii)(D).
It should record reads as well, since most questions an audit log has to answer are about access, such as which staff member opened a patient's record without being on the care team. Record every view, search and export of patient data, including denied attempts, with the actor, the patient, the action, the outcome and a request ID.
Add a BEFORE UPDATE OR DELETE row trigger and a BEFORE TRUNCATE statement trigger that raise an exception. If old rows must be purged after the retention period, let the trigger allow deletes only of rows older than that period and only when a SECURITY DEFINER function owned by a dedicated role sets the session flag. The application role can execute that function and cannot delete from the table directly. Database owners can still drop triggers, so keep a copy outside the application database too.
164.312(b) does not name a period, and six years is a defensible starting point, since it matches the HIPAA documentation retention rule in 164.316(b)(2), and state law or contracts can require longer. Decide the period with your compliance team and encode it in one place.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.