zynactradocs

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.

PackageUse
accessNew(Config), HasPermission, Check, CheckMany, Assert, Plan, Explain, Summary, Invalidate
accesshttpAuthenticate, WithSubject, SubjectFrom, Guard{Strict}.Require / RequireCapability, Handler(Config) (the access API), WriteError
sqlfilterTranslate(plan, cols, Options{StartAt}) returning {SQL, Args}
adminthe admin service (New(access, store)) and Bootstrap
sqlstorethe Postgres store for database/sql (lib/pq)
migrateApply(ctx, db): the access schema
pqlistenListen(ctx, dsn, access, log): cache invalidation through NOTIFY
accesstestStartCerbos, 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 fresh

accessgen 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
	}
}

See Field-level permissions.

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.

Edit on GitHub

On this page