You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/06/17 20:30:07 UTC

(airavata-custos) 07/15: Read caller identity from context, not the user-id header

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch auth-endpoints
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 8535531c9209ceea997f72d6f20bb95db34d39cb
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Jun 16 19:25:42 2026 -0400

    Read caller identity from context, not the user-id header
    
    Co-Authored-By: Claude Opus 4.7 <no...@anthropic.com>
---
 internal/server/auth.go      | 24 +++++++++++++++---------
 internal/server/privilege.go | 21 +++++++++------------
 internal/server/role.go      | 43 ++++++++++++++++++-------------------------
 3 files changed, 42 insertions(+), 46 deletions(-)

diff --git a/internal/server/auth.go b/internal/server/auth.go
index b6aaf2a0d..44f51f855 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -24,14 +24,21 @@ import (
 	"sync"
 	"time"
 
+	"github.com/apache/airavata-custos/pkg/identity"
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
-// callerHeader identifies the caller. TODO A JWT-verification middleware should set
-// this header from the verified `sub` claim after validating the token
-// against the IdP's JWKS endpoint. Until that lands, the value
-// is supplied directly by the caller.
-const callerHeader = "X-Custos-User-Id"
+// callerOrUnauthorized returns the verified caller's user ID, writing a 401
+// response if the request is unauthenticated. Returns "" on rejection;
+// the caller must return immediately when it sees an empty string.
+func callerOrUnauthorized(w http.ResponseWriter, r *http.Request) string {
+	caller := identity.CallerFromContext(r.Context())
+	if caller == nil {
+		writeError(w, http.StatusUnauthorized, errors.New("unauthenticated"))
+		return ""
+	}
+	return caller.UserID
+}
 
 // authProfileTTL bounds how long the middleware will trust a cached
 // privilege set before re-reading the DB.
@@ -110,19 +117,18 @@ func (c *authProfileCache) invalidateAll() {
 }
 
 // requirePrivilege returns a middleware that admits the request only if the
-// caller (identified by callerHeader) holds the named active privilege.
+// verified caller holds the named active privilege.
 //
 // Responses:
-//   - 401 Unauthorized - no caller header
+//   - 401 Unauthorized - caller not on context (middleware should have set it)
 //   - 403 Forbidden - caller is identified but does not hold the privilege
 //   - 503 Service Unavailable - auth-profile lookup failed
 //
 // Fail-closed: a DB failure NEVER reads as 403
 func (s *Server) requirePrivilege(p models.PrivilegeKey, next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		callerID := r.Header.Get(callerHeader)
+		callerID := callerOrUnauthorized(w, r)
 		if callerID == "" {
-			writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 			return
 		}
 		profile, err := s.lookupAuthProfile(r.Context(), callerID)
diff --git a/internal/server/privilege.go b/internal/server/privilege.go
index e33b1acb1..1f8839dc6 100644
--- a/internal/server/privilege.go
+++ b/internal/server/privilege.go
@@ -27,16 +27,15 @@ import (
 // @Summary	Get caller's effective privileges
 // @Description	Effective set is direct grants UNION every privilege carried by every role the caller holds.
 // @Tags	Caller
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Produce	json
 // @Success	200	{object}	object{privileges=[]models.PrivilegeKey}	"Effective privilege set"
-// @Failure	401	{object}	object{error=string}	"Missing X-Custos-User-Id header"
+// @Failure	401	{object}	object{error=string}	"Unauthenticated"
 // @Failure	503	{object}	object{error=string}	"Auth lookup failed"
 // @Router	/user/privileges [get]
 func (s *Server) getCallerPrivileges(w http.ResponseWriter, r *http.Request) {
-	callerID := r.Header.Get(callerHeader)
+	callerID := callerOrUnauthorized(w, r)
 	if callerID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	profile, err := s.lookupAuthProfile(r.Context(), callerID)
@@ -117,14 +116,14 @@ type grantPrivilegeRequest struct {
 
 // @Summary	Grant a direct privilege to a user
 // @Tags	Privileges
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Produce	json
 // @Param	id	path	string	true	"User ID"
 // @Param	request	body	grantPrivilegeRequest	true	"Grant payload"
 // @Success	201	{object}	models.UserPrivilege
 // @Failure	400	{object}	object{error=string}	"Bad request"
-// @Failure	401	{object}	object{error=string}	"Missing caller header"
+// @Failure	401	{object}	object{error=string}	"Unauthenticated"
 // @Failure	403	{object}	object{error=string}	"Caller lacks privileges:grant"
 // @Failure	409	{object}	object{error=string}	"Privilege already active for user"
 // @Router	/users/{id}/privileges [post]
@@ -134,9 +133,8 @@ func (s *Server) grantPrivilege(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("user id is required"))
 		return
 	}
-	granterID := r.Header.Get(callerHeader)
+	granterID := callerOrUnauthorized(w, r)
 	if granterID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req grantPrivilegeRequest
@@ -160,14 +158,14 @@ type revokePrivilegeRequest struct {
 // @Summary	Revoke a direct privilege from a user
 // @Description	`privileges:grant` itself cannot be self-revoked or revoked from the last holder.
 // @Tags	Privileges
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Param	id	path	string	true	"User ID"
 // @Param	key	path	models.PrivilegeKey	true	"Privilege key"
 // @Param	request	body	revokePrivilegeRequest	false	"Optional reason"
 // @Success	204	"No Content"
 // @Failure	400	{object}	object{error=string}	"Self-revoke meta, last-holder, or unknown key"
-// @Failure	401	{object}	object{error=string}	"Missing caller header"
+// @Failure	401	{object}	object{error=string}	"Unauthenticated"
 // @Failure	403	{object}	object{error=string}	"Caller lacks privileges:grant"
 // @Failure	404	{object}	object{error=string}	"No active grant for that key"
 // @Router	/users/{id}/privileges/{key} [delete]
@@ -178,9 +176,8 @@ func (s *Server) revokePrivilege(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("user id and privilege key are required"))
 		return
 	}
-	revokerID := r.Header.Get(callerHeader)
+	revokerID := callerOrUnauthorized(w, r)
 	if revokerID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req revokePrivilegeRequest
diff --git a/internal/server/role.go b/internal/server/role.go
index ee91ea7b7..c7a2afd68 100644
--- a/internal/server/role.go
+++ b/internal/server/role.go
@@ -26,7 +26,7 @@ import (
 
 // @Summary	List all roles
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Produce	json
 // @Success	200	{array}	models.Role
 // @Failure	401	{object}	object{error=string}
@@ -43,7 +43,7 @@ func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
 
 // @Summary	Get a role with its privilege bundle
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Role ID"
 // @Success	200	{object}	object{role=models.Role,privileges=[]models.PrivilegeKey}
@@ -79,7 +79,7 @@ type createRoleRequest struct {
 
 // @Summary	Create a role
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Produce	json
 // @Param	request	body	createRoleRequest	true	"Role payload"
@@ -89,9 +89,8 @@ type createRoleRequest struct {
 // @Failure	409	{object}	object{error=string}	"Role name collides"
 // @Router	/roles [post]
 func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
-	actorID := r.Header.Get(callerHeader)
+	actorID := callerOrUnauthorized(w, r)
 	if actorID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req createRoleRequest
@@ -115,7 +114,7 @@ type updateRoleRequest struct {
 // @Summary	Update role name / description
 // @Description	System roles cannot be renamed.
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Produce	json
 // @Param	id	path	string	true	"Role ID"
@@ -130,9 +129,8 @@ func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
 		return
 	}
-	actorID := r.Header.Get(callerHeader)
+	actorID := callerOrUnauthorized(w, r)
 	if actorID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req updateRoleRequest
@@ -152,7 +150,7 @@ func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
 // @Summary	Delete a role
 // @Description	System roles cannot be deleted. CASCADE drops every assignment of this role.
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Param	id	path	string	true	"Role ID"
 // @Success	204	"No Content"
 // @Failure	400	{object}	object{error=string}	"System role / unknown role"
@@ -164,9 +162,8 @@ func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
 		return
 	}
-	actorID := r.Header.Get(callerHeader)
+	actorID := callerOrUnauthorized(w, r)
 	if actorID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	if err := s.svc.DeleteRole(r.Context(), roleID, actorID); err != nil {
@@ -184,7 +181,7 @@ type rolePrivilegeRequest struct {
 // @Summary	Add a privilege to a role
 // @Description	The new privilege propagates to every current holder.
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Param	id	path	string	true	"Role ID"
 // @Param	request	body	rolePrivilegeRequest	true	"Privilege key"
@@ -199,9 +196,8 @@ func (s *Server) addRolePrivilege(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
 		return
 	}
-	actorID := r.Header.Get(callerHeader)
+	actorID := callerOrUnauthorized(w, r)
 	if actorID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req rolePrivilegeRequest
@@ -220,7 +216,7 @@ func (s *Server) addRolePrivilege(w http.ResponseWriter, r *http.Request) {
 // @Summary	Remove a privilege from a role
 // @Description	Removal propagates to every holder. Refuses to remove `privileges:grant` or `roles:manage` if that would leave no role anywhere carrying it.
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Param	id	path	string	true	"Role ID"
 // @Param	key	path	models.PrivilegeKey	true	"Privilege key"
 // @Success	204	"No Content"
@@ -235,9 +231,8 @@ func (s *Server) removeRolePrivilege(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("role id and privilege key are required"))
 		return
 	}
-	actorID := r.Header.Get(callerHeader)
+	actorID := callerOrUnauthorized(w, r)
 	if actorID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	if err := s.svc.RemovePrivilegeFromRole(r.Context(), roleID, key, actorID); err != nil {
@@ -250,7 +245,7 @@ func (s *Server) removeRolePrivilege(w http.ResponseWriter, r *http.Request) {
 
 // @Summary	List roles a user holds
 // @Tags	Role Assignments
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"User ID"
 // @Success	200	{array}	models.UserRole
@@ -272,7 +267,7 @@ func (s *Server) listUserRoles(w http.ResponseWriter, r *http.Request) {
 
 // @Summary	List users holding the role
 // @Tags	Roles
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Produce	json
 // @Param	id	path	string	true	"Role ID"
 // @Success	200	{array}	models.UserRole
@@ -299,7 +294,7 @@ type grantRoleRequest struct {
 
 // @Summary	Grant a role to a user
 // @Tags	Role Assignments
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Produce	json
 // @Param	id	path	string	true	"User ID"
@@ -315,9 +310,8 @@ func (s *Server) grantRoleToUser(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("user id is required"))
 		return
 	}
-	granterID := r.Header.Get(callerHeader)
+	granterID := callerOrUnauthorized(w, r)
 	if granterID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req grantRoleRequest
@@ -341,7 +335,7 @@ type revokeRoleRequest struct {
 // @Summary	Revoke a role from a user
 // @Description	Refuses if revoking would leave no holder of `privileges:grant` or `roles:manage` anywhere (last-meta-holder guard).
 // @Tags	Role Assignments
-// @Security	CustosUserHeader
+// @Security	BearerAuth
 // @Accept	json
 // @Param	id	path	string	true	"User ID"
 // @Param	roleId	path	string	true	"Role ID"
@@ -358,9 +352,8 @@ func (s *Server) revokeRoleFromUser(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, errors.New("user id and role id are required"))
 		return
 	}
-	revokerID := r.Header.Get(callerHeader)
+	revokerID := callerOrUnauthorized(w, r)
 	if revokerID == "" {
-		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
 		return
 	}
 	var req revokeRoleRequest