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

1// packages/api/internal/middleware/rbac.go (verified)
2func RequireRoles(roles ...string) func(http.Handler) http.Handler {
3 allowed := make(map[string]bool, len(roles))
4 for _, r := range roles {
5 allowed[r] = true
6 }
7 return func(next http.Handler) http.Handler {
8 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9 userRole := GetAuthRole(r.Context())
10
11 // Admin always passes.
12 if userRole == "admin" {
13 next.ServeHTTP(w, r); return
14 }
15 // API-key and DID-Auth schemes pass through (handler enforces ownership).
16 scheme := GetAuthScheme(r.Context())
17 if scheme == "apikey" || scheme == "didauth" {
18 next.ServeHTTP(w, r); return
19 }
20 if userRole == "" {
21 writeRBACError(w, http.StatusUnauthorized, "authentication required"); return
22 }
23 if !allowed[userRole] {
24 writeRBACError(w, http.StatusForbidden, "insufficient permissions for this resource"); return
25 }
26 next.ServeHTTP(w, r)
27 })
28 }
29}

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

1// packages/api/internal/router/router.go (illustrative; matches actual routes)
2r.Route("/api/v1", func(r chi.Router) {
3 // Public — no auth, no rate-limit aware of identity
4 r.Get("/health", h.healthH.Get)
5 r.Get("/.well-known/agent.json", h.agentH.WellKnownCard)
6 r.Post("/auth/register", h.authH.Register)
7 r.Post("/credentials/verify", h.vcH.Verify)
8 r.Post("/presentations/verify", h.vcH.VerifyPresentation)
9
10 // Authenticated
11 r.Group(func(r chi.Router) {
12 r.Use(h.authMW.Handler)
13
14 // Holder-and-up endpoints
15 r.Get("/dashboard/stats", h.dashboardH.Stats)
16 r.Post("/dids", h.didH.Create)
17
18 // Issuer-only
19 r.Group(func(r chi.Router) {
20 r.Use(middleware.RequireRoles("issuer"))
21 r.Post("/credentials/schemas", h.vcH.CreateSchema)
22 r.Post("/credentials/issue", h.vcH.Issue)
23 r.Post("/credentials/revoke", h.vcH.Revoke)
24 })
25
26 // Verifier-only
27 r.Group(func(r chi.Router) {
28 r.Use(middleware.RequireRoles("verifier"))
29 r.Post("/verifications", h.verifierH.Create)
30 r.Post("/verifier/trusted-issuers", h.verifierH.AddTrustedIssuer)
31 r.Delete("/verifier/trusted-issuers/{id}", h.verifierH.RemoveTrustedIssuer)
32 })
33 })
34})

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:

1// service/did.go (illustrative)
2func (s *DIDService) UpdateDID(ctx context.Context, did string, doc DIDDocument) error {
3 callerSub := middleware.GetAuthSubject(ctx)
4 callerDID := middleware.GetAuthDID(ctx)
5 callerRole := middleware.GetAuthRole(ctx)
6
7 rec, err := s.repo.GetDID(ctx, did)
8 if err != nil {
9 return ErrDIDNotFound
10 }
11 // Admins skip ownership.
12 if callerRole == "admin" {
13 return s.update(ctx, rec, doc)
14 }
15 // Owner check: either DID-Auth principal == DID, or owner.user_id == callerSub.
16 if callerDID == did || rec.OwnerUserID == callerSub {
17 return s.update(ctx, rec, doc)
18 }
19 return ErrForbidden
20}

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):

1HTTP/1.1 403 Forbidden
2Content-Type: application/json
3
4{
5 "success": false,
6 "error": {
7 "code": "FORBIDDEN",
8 "message": "insufficient permissions for this resource"
9 }
10}

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

1HTTP/1.1 401 Unauthorized
2WWW-Authenticate: DIDAuth realm="IDA Platform"
3Content-Type: application/json
4
5{
6 "error": "invalid or expired token",
7 "status": 401
8}

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.