Skip to navigation

Server-Side Enforcement

View as Markdown

The authoritative RBAC code lives in packages/api/internal/middleware/rbac.go. Every protected route is wrapped with middleware.RequireRoles(roles...) in packages/api/internal/router/router.go.

13.4.1 Middleware contract

// packages/api/internal/middleware/rbac.go (verified)
func RequireRoles(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := GetAuthRole(r.Context())
// Admin always passes.
if userRole == "admin" {
next.ServeHTTP(w, r); return
}
// API-key and DID-Auth schemes pass through (handler enforces ownership).
scheme := GetAuthScheme(r.Context())
if scheme == "apikey" || scheme == "didauth" {
next.ServeHTTP(w, r); return
}
if userRole == "" {
writeRBACError(w, http.StatusUnauthorized, "authentication required"); return
}
if !allowed[userRole] {
writeRBACError(w, http.StatusForbidden, "insufficient permissions for this resource"); return
}
next.ServeHTTP(w, r)
})
}
}

Three things to know:

  1. admin is hard-wired as a superset role at the middleware layer — it bypasses every check.
  2. apikey and didauth schemes pass through. This is intentional — both schemes already proved an identity (a long-lived secret or a fresh DID signature). The handler is then responsible for ownership / resource-scope checks.
  3. Empty role yields 401, wrong role yields 403. Clients should distinguish: 401 = “log in”; 403 = “this account can’t do that”.

13.4.2 Wiring in the router

// packages/api/internal/router/router.go (illustrative; matches actual routes)
r.Route("/api/v1", func(r chi.Router) {
// Public — no auth, no rate-limit aware of identity
r.Get("/health", h.healthH.Get)
r.Get("/.well-known/agent.json", h.agentH.WellKnownCard)
r.Post("/auth/register", h.authH.Register)
r.Post("/credentials/verify", h.vcH.Verify)
r.Post("/presentations/verify", h.vcH.VerifyPresentation)
// Authenticated
r.Group(func(r chi.Router) {
r.Use(h.authMW.Handler)
// Holder-and-up endpoints
r.Get("/dashboard/stats", h.dashboardH.Stats)
r.Post("/dids", h.didH.Create)
// Issuer-only
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("issuer"))
r.Post("/credentials/schemas", h.vcH.CreateSchema)
r.Post("/credentials/issue", h.vcH.Issue)
r.Post("/credentials/revoke", h.vcH.Revoke)
})
// Verifier-only
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("verifier"))
r.Post("/verifications", h.verifierH.Create)
r.Post("/verifier/trusted-issuers", h.verifierH.AddTrustedIssuer)
r.Delete("/verifier/trusted-issuers/{id}", h.verifierH.RemoveTrustedIssuer)
})
})
})

13.4.3 Owner-only enforcement inside handlers

Some endpoints (e.g. PUT /dids/{did}) accept any authenticated caller at the router layer but enforce resource ownership inside the handler. The handler typically:

// service/did.go (illustrative)
func (s *DIDService) UpdateDID(ctx context.Context, did string, doc DIDDocument) error {
callerSub := middleware.GetAuthSubject(ctx)
callerDID := middleware.GetAuthDID(ctx)
callerRole := middleware.GetAuthRole(ctx)
rec, err := s.repo.GetDID(ctx, did)
if err != nil {
return ErrDIDNotFound
}
// Admins skip ownership.
if callerRole == "admin" {
return s.update(ctx, rec, doc)
}
// Owner check: either DID-Auth principal == DID, or owner.user_id == callerSub.
if callerDID == did || rec.OwnerUserID == callerSub {
return s.update(ctx, rec, doc)
}
return ErrForbidden
}

Best practice for new endpoints: if the resource has an owner column, always perform the owner check after RBAC, never before. RBAC restricts the role; ownership restricts the instance.

13.4.4 Error response shape

Failed RBAC checks return the canonical error envelope (verified in rbac.go:73-81):

HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "insufficient permissions for this resource"
}
}

A failed auth (no header, malformed scheme, expired JWT) instead returns:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DIDAuth realm="IDA Platform"
Content-Type: application/json
{
"error": "invalid or expired token",
"status": 401
}

Why two shapes? RBAC errors are produced by rbac.go:writeRBACError and follow the new { success, error: { code, message } } envelope. Auth errors are produced by auth.go:writeAuthError and use the legacy { error, status } envelope. SDK clients should accept both shapes; new endpoints should standardise on the RBAC shape.