Go
Wire zynactra/access into a net/http and database/sql service: startup, routes, record checks, lists and errors.
Module github.com/zynactra/access/go. examples/go-app is the complete service this page quotes.
| Package | Use |
|---|---|
access | New(Config), HasPermission, Check, CheckMany, Assert, Plan, Explain, Summary, Invalidate |
accesshttp | Authenticate, WithSubject, SubjectFrom, Guard{Strict}.Require / RequireCapability, Handler(Config) (the access API), WriteError |
sqlfilter | Translate(plan, cols, Options{StartAt}) returning {SQL, Args} |
admin | the admin service (New(access, store)) and Bootstrap |
sqlstore | the Postgres store for database/sql (lib/pq) |
migrate | Apply(ctx, db): the access schema |
pqlisten | Listen(ctx, dsn, access, log): cache invalidation through NOTIFY |
accesstest | StartCerbos, NewDB, StaticResolver, MemStore for tests |
Startup
if _, err := migrate.Apply(ctx, db); err != nil { return err }
store := sqlstore.New(db)
if err := store.SyncCatalog(ctx, accessgen.Manifest); err != nil { return err } // catalog + system roles
a, err := access.New(access.Config{
Manifest: accessgen.Manifest,
Cerbos: access.NewCerbosHTTP(os.Getenv("CERBOS_URL")), // the sidecar, http://127.0.0.1:3592
Store: store,
Resolver: app.Resolver{DB: db}, // PrincipalAttrs and ExpandSet from your tables
Strict: devOrTest,
})
if err != nil { return err }
adm := admin.New(a, store)
go func() { _ = pqlisten.Listen(ctx, dsn, a, slog.Default()) }() // keep every instance's cache freshaccessgen is the package zaccess gen writes (out.go in zaccess.yaml); accessgen.Manifest is the compiled manifest embedded in it. Other Config options are in Configuration.
The subject
Your auth middleware verifies the token, then puts the subject in the request context, with accesshttp.Authenticate or your own middleware calling accesshttp.WithSubject:
handler := accesshttp.Authenticate(func(r *http.Request) (access.Subject, error) {
claims, err := verify(r) // your token verification
if err != nil {
return access.Subject{}, err // 401
}
return access.Subject{UserID: claims.Sub, OrgID: claims.Org, IdP: claims.Iss, Groups: claims.Groups}, nil
})(mux)Groups come only from the verified token: they drive group mappings.
Routes
g := &accesshttp.Guard{Access: a, Strict: devOrTest}
mux.Handle("POST /leave-requests", g.Require(accessgen.LeaveRequestCreate, http.HandlerFunc(create)))
mux.Handle("GET /leave-requests", g.Require(accessgen.LeaveRequestRead, http.HandlerFunc(list)))
mux.Handle("GET /leave-requests/{id}", g.Require(accessgen.LeaveRequestRead, http.HandlerFunc(get)))
mux.Handle("POST /leave-requests/{id}/approve", g.Require(accessgen.LeaveRequestApprove, http.HandlerFunc(approve)))
// The access API for the React client and the admin screens.
mux.Handle("/access/", http.StripPrefix("/access", accesshttp.Handler(accesshttp.Config{
Service: adm,
Loaders: map[string]accesshttp.Loader{"leave.request": loadLeaveResources}, // for /check and /explain
})))Require answers 401 without a subject and 403 when the permission is held nowhere, then runs the handler and, in strict mode, fails it with 500 if it never checked that permission against a record. RequireCapability is for routes with no record.
The resource
One function per kind, so every check sends the same attributes:
func (l *LeaveRequest) resource() access.Resource {
return access.Resource{Kind: "leave.request", ID: l.ID, Attr: map[string]any{
"orgId": l.orgID, "ownerId": l.OwnerID, "ownerPath": l.ownerPath,
"orgUnitId": l.OrgUnitID, "status": l.Status, "days": l.Days,
}}
}Numbers must be Go numbers: scan numeric columns into float64 (lr.days::float8).
Record checks
func (a *App) approve(w http.ResponseWriter, r *http.Request) {
s, _ := accesshttp.SubjectFrom(r.Context())
l, err := a.loadLeave(r.Context(), s.OrgID, r.PathValue("id")) // always in the subject's org
if err == nil && l == nil {
err = &access.Error{Code: access.CodeNotFound, Message: "leave request not found"}
}
if err == nil {
err = a.Access.Assert(r.Context(), s, accessgen.LeaveRequestApprove, l.resource())
}
if err != nil {
accesshttp.WriteError(w, err) // 404, 403, or 503 when Cerbos is down
return
}
// update leave_requests set status = 'approved' where id = $1 and status = 'pending'
}For reads, turn a denial into 404 so IDs can't be probed:
if access.IsDenied(err) {
err = &access.Error{Code: access.CodeNotFound, Message: "leave request not found"}
}For creates, build the record you're about to insert, Assert it, then insert.
Lists
plan, err := a.Access.Plan(ctx, s, accessgen.LeaveRequestRead)
if err != nil { accesshttp.WriteError(w, err); return }
f, err := sqlfilter.Translate(plan, sqlfilter.Columns{
"orgId": "lr.org_id", "ownerId": "lr.owner_id", "ownerPath": "lr.owner_path",
"orgUnitId": "lr.org_unit_id", "status": "lr.status", "days": "lr.days",
}, sqlfilter.Options{StartAt: 2}) // $1 is the org below
if err != nil { accesshttp.WriteError(w, err); return }
rows, err := db.QueryContext(ctx,
`select ... from leave_requests lr where lr.org_id = $1 and `+f.SQL+` order by lr.created_at limit 50`,
append([]any{s.OrgID}, f.Args...)...)The filter goes in the WHERE clause, before ORDER BY, LIMIT and any count(*). List filters explains the plan kinds, column maps and escaping.
Sensitive fields
items := make([]access.CheckItem, len(rows))
for i, l := range rows {
items[i] = access.CheckItem{Resource: l.resource(), Permissions: []string{accessgen.LeaveRequestReadReason}}
}
res, err := a.Access.CheckMany(ctx, s, items) // one Cerbos call for the page
for i, l := range rows {
if err == nil && res[i].Allowed[accessgen.LeaveRequestReadReason] {
l.Reason = &l.reason
}
}Loaders for the access API
POST /access/check and /access/explain take record IDs, never attributes: the server loads the records itself. Register a loader per kind:
func (a *App) loadLeaveResources(ctx context.Context, s access.Subject, ids []string) (map[string]access.Resource, error) {
rows, err := a.DB.QueryContext(ctx, leaveSelect+` where lr.org_id = $1 and lr.id::text = any($2)`, s.OrgID, pq.Array(ids))
// ... scan each row, out[l.ID] = l.resource()
}IDs a loader doesn't return come back allowed: false. A kind without a loader is refused with 400.
Errors
Errors from access are *access.Error{Code, Reason, Permission, Field, Message}. access.ErrorCode(err) and access.IsDenied(err) classify them; accesshttp.WriteError renders any error as an RFC 9457 problem, with internals hidden. Check returns (false, err) on outages; Assert turns them into a denial with reason unavailable. See Errors.
Admin from code
Seed scripts and onboarding call the admin service directly, as a subject, with every rule applied:
root := access.Subject{UserID: "svc:onboarding", OrgID: org}
_, err := adm.CreateAssignment(ctx, root, admin.AssignmentInput{UserID: user, RoleID: employeeRoleID, ScopeType: "self"})admin.Bootstrap(ctx, a, store, org, user) gives an org its first access_admin, like zaccess bootstrap-admin.