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/05/28 20:31:26 UTC

(airavata-custos) branch privileges-impl updated: Add role-template layer on top of admin privileges

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

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


The following commit(s) were added to refs/heads/privileges-impl by this push:
     new 3f001ce85 Add role-template layer on top of admin privileges
3f001ce85 is described below

commit 3f001ce85b18cedfcf533c685149ad544f59fec3
Author: lahiruj <la...@gmail.com>
AuthorDate: Thu May 28 16:31:20 2026 -0400

    Add role-template layer on top of admin privileges
---
 cmd/server/main.go                             |   4 +-
 internal/db/migrations/000007_roles.down.sql   |  20 +
 internal/db/migrations/000007_roles.up.sql     |  59 ++
 internal/server/auth.go                        |  23 +-
 internal/server/integration_common_test.go     |   3 +
 internal/server/role.go                        | 263 +++++++++
 internal/server/server.go                      |  12 +
 internal/store/role_store.go                   | 141 +++++
 internal/store/store.go                        |  28 +
 internal/store/user_role_store.go              | 144 +++++
 pkg/models/privilege.go                        |   2 +
 pkg/models/role.go                             |  44 ++
 pkg/service/integration_common_test.go         |   3 +
 pkg/service/interface.go                       |  33 +-
 pkg/service/mock.go                            | 782 ++++++++++++++++++++++++-
 pkg/service/role.go                            | 298 ++++++++++
 pkg/service/role_integration_test.go           | 196 +++++++
 pkg/service/service.go                         |   8 +
 pkg/service/user_privilege.go                  | 144 ++---
 pkg/service/user_privilege_integration_test.go |  45 +-
 pkg/service/user_role.go                       | 302 ++++++++++
 pkg/service/user_role_integration_test.go      | 186 ++++++
 22 files changed, 2597 insertions(+), 143 deletions(-)

diff --git a/cmd/server/main.go b/cmd/server/main.go
index 602ffe89b..e74f0f42d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -81,8 +81,8 @@ func run() error {
 	defer stop()
 
 	if email := os.Getenv("CUSTOS_BOOTSTRAP_ADMIN_EMAIL"); email != "" {
-		if err := svc.BootstrapPrivilegeGrant(ctx, email, "env:CUSTOS_BOOTSTRAP_ADMIN_EMAIL"); err != nil {
-			slog.Warn("bootstrap privilege grant failed", "email", email, "error", err)
+		if err := svc.BootstrapSuperAdmin(ctx, email, "env:CUSTOS_BOOTSTRAP_ADMIN_EMAIL"); err != nil {
+			slog.Warn("bootstrap super_admin failed", "email", email, "error", err)
 		}
 	}
 
diff --git a/internal/db/migrations/000007_roles.down.sql b/internal/db/migrations/000007_roles.down.sql
new file mode 100644
index 000000000..369295853
--- /dev/null
+++ b/internal/db/migrations/000007_roles.down.sql
@@ -0,0 +1,20 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+DROP TABLE IF EXISTS user_roles;
+DROP TABLE IF EXISTS role_privileges;
+DROP TABLE IF EXISTS roles;
diff --git a/internal/db/migrations/000007_roles.up.sql b/internal/db/migrations/000007_roles.up.sql
new file mode 100644
index 000000000..298c3b35d
--- /dev/null
+++ b/internal/db/migrations/000007_roles.up.sql
@@ -0,0 +1,59 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+SET NAMES utf8mb4;
+SET time_zone = '+00:00';
+
+-- Roles are named bundles of privileges. Granting a role to a user is
+-- equivalent to granting each of the role's privileges.
+CREATE TABLE IF NOT EXISTS roles
+(
+    id          VARCHAR(255) NOT NULL,
+    name        VARCHAR(64)  NOT NULL,
+    description TEXT         NULL,
+    is_system   TINYINT(1)   NOT NULL DEFAULT 0,
+    created_at  TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    UNIQUE KEY uq_roles_name (name)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
+
+-- Privileges that a role bundles. Holding the role implies holding every
+-- listed privilege.
+CREATE TABLE IF NOT EXISTS role_privileges
+(
+    role_id   VARCHAR(255) NOT NULL,
+    privilege VARCHAR(64)  NOT NULL,
+    added_at  TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (role_id, privilege),
+    CONSTRAINT fk_role_privileges_role FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
+
+-- Active role assignments. Revoke is DELETE; full history lives in audit_events.
+-- A user may hold any number of roles simultaneously.
+CREATE TABLE IF NOT EXISTS user_roles
+(
+    user_id    VARCHAR(255) NOT NULL,
+    role_id    VARCHAR(255) NOT NULL,
+    granted_by VARCHAR(255) NULL,
+    granted_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    reason     TEXT         NULL,
+    PRIMARY KEY (user_id, role_id),
+    KEY idx_user_roles_role (role_id),
+    CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+    CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
+    CONSTRAINT fk_user_roles_granted_by FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
diff --git a/internal/server/auth.go b/internal/server/auth.go
index b3b3b5c5f..b6aaf2a0d 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -100,6 +100,15 @@ func (c *authProfileCache) invalidate(userID string) {
 	delete(c.entries, userID)
 }
 
+// invalidateAll empties the cache. Used when one mutation affects many
+// users at once: adding or removing a privilege from a role (every holder
+// gains/loses it) or deleting a role.
+func (c *authProfileCache) invalidateAll() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.entries = make(map[string]authProfileCacheEntry)
+}
+
 // requirePrivilege returns a middleware that admits the request only if the
 // caller (identified by callerHeader) holds the named active privilege.
 //
@@ -129,20 +138,20 @@ func (s *Server) requirePrivilege(p models.PrivilegeKey, next http.HandlerFunc)
 	}
 }
 
-// lookupAuthProfile returns the caller's current privilege snapshot, hitting
-// the cache first and falling back to the DB. Errors propagate so middleware
-// can fail closed.
+// lookupAuthProfile returns the caller's effective privilege snapshot
+// (direct grants + role-derived privileges), hitting the cache first
+// and falling back to the DB. Errors propagate so middleware can fail closed.
 func (s *Server) lookupAuthProfile(ctx context.Context, userID string) (*authProfile, error) {
 	if cached, ok := s.authCache.get(userID); ok {
 		return cached, nil
 	}
-	grants, err := s.svc.ListUserPrivileges(ctx, userID)
+	keys, err := s.svc.EffectivePrivileges(ctx, userID)
 	if err != nil {
 		return nil, err
 	}
-	profile := &authProfile{privileges: make(map[models.PrivilegeKey]struct{}, len(grants))}
-	for _, g := range grants {
-		profile.privileges[g.Privilege] = struct{}{}
+	profile := &authProfile{privileges: make(map[models.PrivilegeKey]struct{}, len(keys))}
+	for _, k := range keys {
+		profile.privileges[k] = struct{}{}
 	}
 	s.authCache.set(userID, profile)
 	return profile, nil
diff --git a/internal/server/integration_common_test.go b/internal/server/integration_common_test.go
index 490eb8923..f3a9282c7 100644
--- a/internal/server/integration_common_test.go
+++ b/internal/server/integration_common_test.go
@@ -75,6 +75,9 @@ func setupTestStack(t *testing.T) (*sqlx.DB, *service.Service, *Server) {
 func truncateAll(t *testing.T, database *sqlx.DB) {
 	t.Helper()
 	tables := []string{
+		"user_roles",
+		"role_privileges",
+		"roles",
 		"user_privileges",
 		"audit_events",
 		"user_identities",
diff --git a/internal/server/role.go b/internal/server/role.go
new file mode 100644
index 000000000..360763417
--- /dev/null
+++ b/internal/server/role.go
@@ -0,0 +1,263 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package server
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
+	rows, err := s.svc.ListRoles(r.Context())
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, rows)
+}
+
+// getRole returns the role plus its privilege bundle in one response.
+func (s *Server) getRole(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	if roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
+		return
+	}
+	role, err := s.svc.GetRole(r.Context(), roleID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	keys, err := s.svc.ListRolePrivileges(r.Context(), roleID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"role":       role,
+		"privileges": keys,
+	})
+}
+
+type createRoleRequest struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
+func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
+	actorID := r.Header.Get(callerHeader)
+	if actorID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	var req createRoleRequest
+	if err := decodeJSON(r, &req); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	role, err := s.svc.CreateRole(r.Context(), req.Name, req.Description, actorID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusCreated, role)
+}
+
+type updateRoleRequest struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
+func (s *Server) updateRole(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	if roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
+		return
+	}
+	actorID := r.Header.Get(callerHeader)
+	if actorID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	var req updateRoleRequest
+	if err := decodeJSON(r, &req); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	role, err := s.svc.UpdateRole(r.Context(), roleID, req.Name, req.Description, actorID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidateAll()
+	writeJSON(w, http.StatusOK, role)
+}
+
+func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	if roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
+		return
+	}
+	actorID := r.Header.Get(callerHeader)
+	if actorID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	if err := s.svc.DeleteRole(r.Context(), roleID, actorID); err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidateAll()
+	w.WriteHeader(http.StatusNoContent)
+}
+
+type rolePrivilegeRequest struct {
+	Privilege models.PrivilegeKey `json:"privilege"`
+}
+
+func (s *Server) addRolePrivilege(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	if roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
+		return
+	}
+	actorID := r.Header.Get(callerHeader)
+	if actorID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	var req rolePrivilegeRequest
+	if err := decodeJSON(r, &req); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	if err := s.svc.AddPrivilegeToRole(r.Context(), roleID, req.Privilege, actorID); err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidateAll()
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) removeRolePrivilege(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	key := models.PrivilegeKey(r.PathValue("key"))
+	if roleID == "" || key == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id and privilege key are required"))
+		return
+	}
+	actorID := r.Header.Get(callerHeader)
+	if actorID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	if err := s.svc.RemovePrivilegeFromRole(r.Context(), roleID, key, actorID); err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidateAll()
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) listUserRoles(w http.ResponseWriter, r *http.Request) {
+	userID := r.PathValue("id")
+	if userID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("user id is required"))
+		return
+	}
+	rows, err := s.svc.ListUserRoles(r.Context(), userID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, rows)
+}
+
+func (s *Server) listRoleHolders(w http.ResponseWriter, r *http.Request) {
+	roleID := r.PathValue("id")
+	if roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("role id is required"))
+		return
+	}
+	rows, err := s.svc.ListRoleHolders(r.Context(), roleID)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, rows)
+}
+
+type grantRoleRequest struct {
+	RoleID string `json:"role_id"`
+	Reason string `json:"reason"`
+}
+
+func (s *Server) grantRoleToUser(w http.ResponseWriter, r *http.Request) {
+	userID := r.PathValue("id")
+	if userID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("user id is required"))
+		return
+	}
+	granterID := r.Header.Get(callerHeader)
+	if granterID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	var req grantRoleRequest
+	if err := decodeJSON(r, &req); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	assignment, err := s.svc.GrantRoleToUser(r.Context(), userID, req.RoleID, granterID, req.Reason)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidate(userID)
+	writeJSON(w, http.StatusCreated, assignment)
+}
+
+type revokeRoleRequest struct {
+	Reason string `json:"reason"`
+}
+
+func (s *Server) revokeRoleFromUser(w http.ResponseWriter, r *http.Request) {
+	userID := r.PathValue("id")
+	roleID := r.PathValue("roleId")
+	if userID == "" || roleID == "" {
+		writeError(w, http.StatusBadRequest, errors.New("user id and role id are required"))
+		return
+	}
+	revokerID := r.Header.Get(callerHeader)
+	if revokerID == "" {
+		writeError(w, http.StatusUnauthorized, errors.New("missing "+callerHeader+" header"))
+		return
+	}
+	var req revokeRoleRequest
+	_ = decodeJSON(r, &req)
+	if err := s.svc.RevokeRoleFromUser(r.Context(), userID, roleID, revokerID, req.Reason); err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	s.authCache.invalidate(userID)
+	s.authCache.invalidate(revokerID)
+	w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 15d637874..6ca8f959c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -154,6 +154,18 @@ func (s *Server) routes() {
 	s.mux.HandleFunc("GET /privileges/{key}/holders", s.requirePrivilege(models.PrivilegeGrant, s.listPrivilegeHolders))
 	s.mux.HandleFunc("POST /users/{id}/privileges", s.requirePrivilege(models.PrivilegeGrant, s.grantPrivilege))
 	s.mux.HandleFunc("DELETE /users/{id}/privileges/{key}", s.requirePrivilege(models.PrivilegeGrant, s.revokePrivilege))
+
+	s.mux.HandleFunc("GET /roles", s.requirePrivilege(models.PrivilegeRolesManage, s.listRoles))
+	s.mux.HandleFunc("POST /roles", s.requirePrivilege(models.PrivilegeRolesManage, s.createRole))
+	s.mux.HandleFunc("GET /roles/{id}", s.requirePrivilege(models.PrivilegeRolesManage, s.getRole))
+	s.mux.HandleFunc("PUT /roles/{id}", s.requirePrivilege(models.PrivilegeRolesManage, s.updateRole))
+	s.mux.HandleFunc("DELETE /roles/{id}", s.requirePrivilege(models.PrivilegeRolesManage, s.deleteRole))
+	s.mux.HandleFunc("POST /roles/{id}/privileges", s.requirePrivilege(models.PrivilegeRolesManage, s.addRolePrivilege))
+	s.mux.HandleFunc("DELETE /roles/{id}/privileges/{key}", s.requirePrivilege(models.PrivilegeRolesManage, s.removeRolePrivilege))
+	s.mux.HandleFunc("GET /roles/{id}/holders", s.requirePrivilege(models.PrivilegeRolesManage, s.listRoleHolders))
+	s.mux.HandleFunc("GET /users/{id}/roles", s.requirePrivilege(models.PrivilegeRolesManage, s.listUserRoles))
+	s.mux.HandleFunc("POST /users/{id}/roles", s.requirePrivilege(models.PrivilegeRolesManage, s.grantRoleToUser))
+	s.mux.HandleFunc("DELETE /users/{id}/roles/{roleId}", s.requirePrivilege(models.PrivilegeRolesManage, s.revokeRoleFromUser))
 }
 
 func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
diff --git a/internal/store/role_store.go b/internal/store/role_store.go
new file mode 100644
index 000000000..4d3f5d53d
--- /dev/null
+++ b/internal/store/role_store.go
@@ -0,0 +1,141 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+const roleColumns = "id, name, description, is_system, created_at"
+
+type mysqlRoleStore struct {
+	db *sqlx.DB
+}
+
+func NewRoleStore(db *sqlx.DB) RoleStore {
+	return &mysqlRoleStore{db: db}
+}
+
+func (s *mysqlRoleStore) FindByID(ctx context.Context, id string) (*models.Role, error) {
+	var r models.Role
+	err := s.db.GetContext(ctx, &r,
+		`SELECT `+roleColumns+` FROM roles WHERE id = ?`, id)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &r, nil
+}
+
+func (s *mysqlRoleStore) FindByName(ctx context.Context, name string) (*models.Role, error) {
+	var r models.Role
+	err := s.db.GetContext(ctx, &r,
+		`SELECT `+roleColumns+` FROM roles WHERE name = ?`, name)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &r, nil
+}
+
+func (s *mysqlRoleStore) List(ctx context.Context) ([]models.Role, error) {
+	var rows []models.Role
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+roleColumns+` FROM roles ORDER BY name`)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+func (s *mysqlRoleStore) Create(ctx context.Context, tx *sql.Tx, r *models.Role) error {
+	_, err := tx.ExecContext(ctx,
+		`INSERT INTO roles (id, name, description, is_system) VALUES (?, ?, ?, ?)`,
+		r.ID, r.Name, r.Description, r.IsSystem)
+	return err
+}
+
+func (s *mysqlRoleStore) Update(ctx context.Context, tx *sql.Tx, r *models.Role) error {
+	_, err := tx.ExecContext(ctx,
+		`UPDATE roles SET name = ?, description = ? WHERE id = ?`,
+		r.Name, r.Description, r.ID)
+	return err
+}
+
+func (s *mysqlRoleStore) Delete(ctx context.Context, tx *sql.Tx, id string) error {
+	_, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, id)
+	return err
+}
+
+func (s *mysqlRoleStore) ListPrivileges(ctx context.Context, roleID string) ([]models.PrivilegeKey, error) {
+	var keys []models.PrivilegeKey
+	err := s.db.SelectContext(ctx, &keys,
+		`SELECT privilege FROM role_privileges WHERE role_id = ? ORDER BY privilege`, roleID)
+	if err != nil {
+		return nil, err
+	}
+	return keys, nil
+}
+
+func (s *mysqlRoleStore) AddPrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) error {
+	_, err := tx.ExecContext(ctx,
+		`INSERT INTO role_privileges (role_id, privilege) VALUES (?, ?)`,
+		roleID, privilege)
+	return err
+}
+
+func (s *mysqlRoleStore) RemovePrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) error {
+	_, err := tx.ExecContext(ctx,
+		`DELETE FROM role_privileges WHERE role_id = ? AND privilege = ?`,
+		roleID, privilege)
+	return err
+}
+
+func (s *mysqlRoleStore) HasPrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) (bool, error) {
+	var n int
+	err := tx.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM role_privileges WHERE role_id = ? AND privilege = ?`,
+		roleID, privilege).Scan(&n)
+	if err != nil {
+		return false, err
+	}
+	return n > 0, nil
+}
+
+// CountRolesGrantingPrivilege returns the number of roles carrying the
+// given key. Used by the last-meta-holder guard.
+func (s *mysqlRoleStore) CountRolesGrantingPrivilege(ctx context.Context, tx *sql.Tx, privilege models.PrivilegeKey) (int, error) {
+	var n int
+	err := tx.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM role_privileges WHERE privilege = ?`,
+		privilege).Scan(&n)
+	if err != nil {
+		return 0, err
+	}
+	return n, nil
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index a17ed5805..b61f37ecf 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -328,6 +328,34 @@ type AuditEventStore interface {
 	Delete(ctx context.Context, tx *sql.Tx, id string) error
 }
 
+// RoleStore covers role definitions and the privilege bundle each carries.
+type RoleStore interface {
+	FindByID(ctx context.Context, id string) (*models.Role, error)
+	FindByName(ctx context.Context, name string) (*models.Role, error)
+	List(ctx context.Context) ([]models.Role, error)
+	Create(ctx context.Context, tx *sql.Tx, r *models.Role) error
+	Update(ctx context.Context, tx *sql.Tx, r *models.Role) error
+	Delete(ctx context.Context, tx *sql.Tx, id string) error
+	ListPrivileges(ctx context.Context, roleID string) ([]models.PrivilegeKey, error)
+	AddPrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) error
+	RemovePrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) error
+	HasPrivilege(ctx context.Context, tx *sql.Tx, roleID string, privilege models.PrivilegeKey) (bool, error)
+	CountRolesGrantingPrivilege(ctx context.Context, tx *sql.Tx, privilege models.PrivilegeKey) (int, error)
+}
+
+// UserRoleStore covers role assignments. Revoke is DELETE; history lives in audit_events.
+type UserRoleStore interface {
+	Find(ctx context.Context, userID, roleID string) (*models.UserRole, error)
+	FindForUpdate(ctx context.Context, tx *sql.Tx, userID, roleID string) (*models.UserRole, error)
+	ListByUser(ctx context.Context, userID string) ([]models.UserRole, error)
+	ListByRole(ctx context.Context, roleID string) ([]models.UserRole, error)
+	ListUserIDsByRole(ctx context.Context, roleID string) ([]string, error)
+	Create(ctx context.Context, tx *sql.Tx, r *models.UserRole) error
+	Delete(ctx context.Context, tx *sql.Tx, userID, roleID string) error
+	PrivilegesForUser(ctx context.Context, userID string) ([]models.PrivilegeKey, error)
+	UsersHoldingPrivilege(ctx context.Context, privilege models.PrivilegeKey) ([]string, error)
+}
+
 // UserPrivilegeStore defines persistence operations for fine-grained admin
 // privileges. Only active grants live in the table; revoke is DELETE. The
 // full grant/revoke history is in audit_events.
diff --git a/internal/store/user_role_store.go b/internal/store/user_role_store.go
new file mode 100644
index 000000000..e4ad37581
--- /dev/null
+++ b/internal/store/user_role_store.go
@@ -0,0 +1,144 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+const userRoleColumns = "user_id, role_id, granted_by, granted_at, reason"
+
+type mysqlUserRoleStore struct {
+	db *sqlx.DB
+}
+
+func NewUserRoleStore(db *sqlx.DB) UserRoleStore {
+	return &mysqlUserRoleStore{db: db}
+}
+
+func (s *mysqlUserRoleStore) Find(ctx context.Context, userID, roleID string) (*models.UserRole, error) {
+	var r models.UserRole
+	err := s.db.GetContext(ctx, &r,
+		`SELECT `+userRoleColumns+` FROM user_roles WHERE user_id = ? AND role_id = ?`,
+		userID, roleID)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &r, nil
+}
+
+func (s *mysqlUserRoleStore) FindForUpdate(ctx context.Context, tx *sql.Tx, userID, roleID string) (*models.UserRole, error) {
+	row := tx.QueryRowContext(ctx,
+		`SELECT `+userRoleColumns+` FROM user_roles WHERE user_id = ? AND role_id = ? FOR UPDATE`,
+		userID, roleID)
+	var r models.UserRole
+	if err := row.Scan(&r.UserID, &r.RoleID, &r.GrantedBy, &r.GrantedAt, &r.Reason); err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &r, nil
+}
+
+func (s *mysqlUserRoleStore) ListByUser(ctx context.Context, userID string) ([]models.UserRole, error) {
+	var rows []models.UserRole
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+userRoleColumns+` FROM user_roles WHERE user_id = ? ORDER BY granted_at`,
+		userID)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+func (s *mysqlUserRoleStore) ListByRole(ctx context.Context, roleID string) ([]models.UserRole, error) {
+	var rows []models.UserRole
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+userRoleColumns+` FROM user_roles WHERE role_id = ? ORDER BY granted_at`,
+		roleID)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+func (s *mysqlUserRoleStore) ListUserIDsByRole(ctx context.Context, roleID string) ([]string, error) {
+	var ids []string
+	err := s.db.SelectContext(ctx, &ids,
+		`SELECT user_id FROM user_roles WHERE role_id = ?`,
+		roleID)
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+func (s *mysqlUserRoleStore) Create(ctx context.Context, tx *sql.Tx, r *models.UserRole) error {
+	_, err := tx.ExecContext(ctx,
+		`INSERT INTO user_roles (user_id, role_id, granted_by, granted_at, reason)
+		 VALUES (?, ?, ?, ?, ?)`,
+		r.UserID, r.RoleID, r.GrantedBy, r.GrantedAt, r.Reason)
+	return err
+}
+
+func (s *mysqlUserRoleStore) Delete(ctx context.Context, tx *sql.Tx, userID, roleID string) error {
+	_, err := tx.ExecContext(ctx,
+		`DELETE FROM user_roles WHERE user_id = ? AND role_id = ?`,
+		userID, roleID)
+	return err
+}
+
+// PrivilegesForUser returns the union of privileges from every role the
+// user holds.
+func (s *mysqlUserRoleStore) PrivilegesForUser(ctx context.Context, userID string) ([]models.PrivilegeKey, error) {
+	var keys []models.PrivilegeKey
+	err := s.db.SelectContext(ctx, &keys,
+		`SELECT DISTINCT rp.privilege
+		 FROM user_roles ur
+		 JOIN role_privileges rp ON rp.role_id = ur.role_id
+		 WHERE ur.user_id = ?`, userID)
+	if err != nil {
+		return nil, err
+	}
+	return keys, nil
+}
+
+// UsersHoldingPrivilege returns user IDs that get this privilege via any
+// role.
+func (s *mysqlUserRoleStore) UsersHoldingPrivilege(ctx context.Context, privilege models.PrivilegeKey) ([]string, error) {
+	var ids []string
+	err := s.db.SelectContext(ctx, &ids,
+		`SELECT DISTINCT ur.user_id
+		 FROM user_roles ur
+		 JOIN role_privileges rp ON rp.role_id = ur.role_id
+		 WHERE rp.privilege = ?`, privilege)
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
diff --git a/pkg/models/privilege.go b/pkg/models/privilege.go
index 47eb3bdac..a67edf43c 100644
--- a/pkg/models/privilege.go
+++ b/pkg/models/privilege.go
@@ -32,6 +32,7 @@ const (
 	PrivilegeSignerRead  PrivilegeKey = "signer:read"
 	PrivilegeSignerWrite PrivilegeKey = "signer:write"
 	PrivilegeGrant       PrivilegeKey = "privileges:grant"
+	PrivilegeRolesManage PrivilegeKey = "roles:manage"
 )
 
 // KnownPrivileges returns the static catalog of declared privilege keys.
@@ -44,6 +45,7 @@ func KnownPrivileges() []PrivilegeKey {
 		PrivilegeSignerRead,
 		PrivilegeSignerWrite,
 		PrivilegeGrant,
+		PrivilegeRolesManage,
 	}
 }
 
diff --git a/pkg/models/role.go b/pkg/models/role.go
new file mode 100644
index 000000000..7ed4b93a6
--- /dev/null
+++ b/pkg/models/role.go
@@ -0,0 +1,44 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package models
+
+import "time"
+
+// SystemRoleSuperAdmin is the bootstrap role: carries privileges:grant
+// and roles:manage. Created on first server start.
+const SystemRoleSuperAdmin = "super_admin"
+
+// Role is a named bundle of privileges. Mutating the bundle propagates to
+// every holder.
+type Role struct {
+	ID          string    `json:"id"          db:"id"`
+	Name        string    `json:"name"        db:"name"`
+	Description *string   `json:"description" db:"description"`
+	IsSystem    bool      `json:"is_system"   db:"is_system"`
+	CreatedAt   time.Time `json:"created_at"  db:"created_at"`
+}
+
+// UserRole is one role assignment. Revoke is DELETE; history lives in
+// audit_events.
+type UserRole struct {
+	UserID    string    `json:"user_id"    db:"user_id"`
+	RoleID    string    `json:"role_id"    db:"role_id"`
+	GrantedBy *string   `json:"granted_by" db:"granted_by"`
+	GrantedAt time.Time `json:"granted_at" db:"granted_at"`
+	Reason    *string   `json:"reason"     db:"reason"`
+}
diff --git a/pkg/service/integration_common_test.go b/pkg/service/integration_common_test.go
index 0a3fbb891..39508711a 100644
--- a/pkg/service/integration_common_test.go
+++ b/pkg/service/integration_common_test.go
@@ -78,6 +78,9 @@ func setupTestDB(t *testing.T) *sqlx.DB {
 func truncateAll(t *testing.T, database *sqlx.DB) {
 	t.Helper()
 	tables := []string{
+		"user_roles",
+		"role_privileges",
+		"roles",
 		"user_privileges",
 		"audit_events",
 		"user_identities",
diff --git a/pkg/service/interface.go b/pkg/service/interface.go
index 52ddd8ac0..1a36e70a3 100644
--- a/pkg/service/interface.go
+++ b/pkg/service/interface.go
@@ -214,18 +214,39 @@ type AuditEventService interface {
 	DeleteAuditEvent(ctx context.Context, id string) error
 }
 
-// UserPrivilegeService exposes the fine-grained capability layer that gates
-// admin surfaces. Privileges are the sole authorization signal; the DB is
-// the source of truth and HasPrivilege re-reads it on every call (callers
-// cache the result if hot).
+// UserPrivilegeService exposes direct privilege grants. HasPrivilege
+// returns true when the user holds the key directly or via any role.
 type UserPrivilegeService interface {
 	GrantPrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, granterID, reason string) (*models.UserPrivilege, error)
 	RevokePrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID, reason string) error
 	HasPrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error)
 	ListUserPrivileges(ctx context.Context, userID string) ([]models.UserPrivilege, error)
 	ListPrivilegeHolders(ctx context.Context, privilege models.PrivilegeKey) ([]models.UserPrivilege, error)
+	EffectivePrivileges(ctx context.Context, userID string) ([]models.PrivilegeKey, error)
 	PrivilegeCatalog() []models.PrivilegeKey
-	BootstrapPrivilegeGrant(ctx context.Context, email, source string) error
+}
+
+// RoleService manages role definitions and their privilege bundles. All
+// mutating calls require roles:manage.
+type RoleService interface {
+	CreateRole(ctx context.Context, name, description, actorID string) (*models.Role, error)
+	UpdateRole(ctx context.Context, roleID, name, description, actorID string) (*models.Role, error)
+	DeleteRole(ctx context.Context, roleID, actorID string) error
+	GetRole(ctx context.Context, roleID string) (*models.Role, error)
+	ListRoles(ctx context.Context) ([]models.Role, error)
+	ListRolePrivileges(ctx context.Context, roleID string) ([]models.PrivilegeKey, error)
+	AddPrivilegeToRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
+	RemovePrivilegeFromRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
+}
+
+// UserRoleService manages role assignments. Granting and revoking require
+// roles:manage.
+type UserRoleService interface {
+	GrantRoleToUser(ctx context.Context, userID, roleID, granterID, reason string) (*models.UserRole, error)
+	RevokeRoleFromUser(ctx context.Context, userID, roleID, revokerID, reason string) error
+	ListUserRoles(ctx context.Context, userID string) ([]models.UserRole, error)
+	ListRoleHolders(ctx context.Context, roleID string) ([]models.UserRole, error)
+	BootstrapSuperAdmin(ctx context.Context, email, source string) error
 }
 
 // CoreService is the aggregate of every domain interface this package exposes.
@@ -250,6 +271,8 @@ type CoreService interface {
 	ComputeAllocationUsageService
 	AuditEventService
 	UserPrivilegeService
+	RoleService
+	UserRoleService
 }
 
 // Compile-time assertion that *Service satisfies the aggregate CoreService.
diff --git a/pkg/service/mock.go b/pkg/service/mock.go
index 2e2393472..b1420d626 100644
--- a/pkg/service/mock.go
+++ b/pkg/service/mock.go
@@ -20,11 +20,14 @@ var _ CoreService = &CoreServiceMock{}
 //
 //		// make and configure a mocked CoreService
 //		mockedCoreService := &CoreServiceMock{
+//			AddPrivilegeToRoleFunc: func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+//				panic("mock out the AddPrivilegeToRole method")
+//			},
 //			AttachResourceToAllocationFunc: func(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error) {
 //				panic("mock out the AttachResourceToAllocation method")
 //			},
-//			BootstrapPrivilegeGrantFunc: func(ctx context.Context, email string, source string) error {
-//				panic("mock out the BootstrapPrivilegeGrant method")
+//			BootstrapSuperAdminFunc: func(ctx context.Context, email string, source string) error {
+//				panic("mock out the BootstrapSuperAdmin method")
 //			},
 //			CreateAuditEventFunc: func(ctx context.Context, e *models.AuditEvent) (*models.AuditEvent, error) {
 //				panic("mock out the CreateAuditEvent method")
@@ -68,6 +71,9 @@ var _ CoreService = &CoreServiceMock{}
 //			CreateProjectFunc: func(ctx context.Context, project *models.Project) (*models.Project, error) {
 //				panic("mock out the CreateProject method")
 //			},
+//			CreateRoleFunc: func(ctx context.Context, name string, description string, actorID string) (*models.Role, error) {
+//				panic("mock out the CreateRole method")
+//			},
 //			CreateUserFunc: func(ctx context.Context, user *models.User) (*models.User, error) {
 //				panic("mock out the CreateUser method")
 //			},
@@ -116,6 +122,9 @@ var _ CoreService = &CoreServiceMock{}
 //			DeleteProjectFunc: func(ctx context.Context, id string) error {
 //				panic("mock out the DeleteProject method")
 //			},
+//			DeleteRoleFunc: func(ctx context.Context, roleID string, actorID string) error {
+//				panic("mock out the DeleteRole method")
+//			},
 //			DeleteUserFunc: func(ctx context.Context, id string) error {
 //				panic("mock out the DeleteUser method")
 //			},
@@ -125,6 +134,9 @@ var _ CoreService = &CoreServiceMock{}
 //			DetachResourceFromAllocationFunc: func(ctx context.Context, allocationID string, resourceID string) error {
 //				panic("mock out the DetachResourceFromAllocation method")
 //			},
+//			EffectivePrivilegesFunc: func(ctx context.Context, userID string) ([]models.PrivilegeKey, error) {
+//				panic("mock out the EffectivePrivileges method")
+//			},
 //			GetAuditEventFunc: func(ctx context.Context, id string) (*models.AuditEvent, error) {
 //				panic("mock out the GetAuditEvent method")
 //			},
@@ -191,6 +203,9 @@ var _ CoreService = &CoreServiceMock{}
 //			GetProjectByOriginatedIDFunc: func(ctx context.Context, originatedID string) (*models.Project, error) {
 //				panic("mock out the GetProjectByOriginatedID method")
 //			},
+//			GetRoleFunc: func(ctx context.Context, roleID string) (*models.Role, error) {
+//				panic("mock out the GetRole method")
+//			},
 //			GetTotalSUUsageForAllocationFunc: func(ctx context.Context, allocationID string) (int64, error) {
 //				panic("mock out the GetTotalSUUsageForAllocation method")
 //			},
@@ -218,6 +233,9 @@ var _ CoreService = &CoreServiceMock{}
 //			GrantPrivilegeFunc: func(ctx context.Context, userID string, privilege models.PrivilegeKey, granterID string, reason string) (*models.UserPrivilege, error) {
 //				panic("mock out the GrantPrivilege method")
 //			},
+//			GrantRoleToUserFunc: func(ctx context.Context, userID string, roleID string, granterID string, reason string) (*models.UserRole, error) {
+//				panic("mock out the GrantRoleToUser method")
+//			},
 //			HasPrivilegeFunc: func(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error) {
 //				panic("mock out the HasPrivilege method")
 //			},
@@ -287,6 +305,15 @@ var _ CoreService = &CoreServiceMock{}
 //			ListResourcesForAllocationFunc: func(ctx context.Context, allocationID string) ([]models.ComputeAllocationResource, error) {
 //				panic("mock out the ListResourcesForAllocation method")
 //			},
+//			ListRoleHoldersFunc: func(ctx context.Context, roleID string) ([]models.UserRole, error) {
+//				panic("mock out the ListRoleHolders method")
+//			},
+//			ListRolePrivilegesFunc: func(ctx context.Context, roleID string) ([]models.PrivilegeKey, error) {
+//				panic("mock out the ListRolePrivileges method")
+//			},
+//			ListRolesFunc: func(ctx context.Context) ([]models.Role, error) {
+//				panic("mock out the ListRoles method")
+//			},
 //			ListUsagesByUserFunc: func(ctx context.Context, userID string) ([]models.ComputeAllocationUsage, error) {
 //				panic("mock out the ListUsagesByUser method")
 //			},
@@ -299,6 +326,9 @@ var _ CoreService = &CoreServiceMock{}
 //			ListUserPrivilegesFunc: func(ctx context.Context, userID string) ([]models.UserPrivilege, error) {
 //				panic("mock out the ListUserPrivileges method")
 //			},
+//			ListUserRolesFunc: func(ctx context.Context, userID string) ([]models.UserRole, error) {
+//				panic("mock out the ListUserRoles method")
+//			},
 //			ListUsersByOrganizationFunc: func(ctx context.Context, organizationID string) ([]models.User, error) {
 //				panic("mock out the ListUsersByOrganization method")
 //			},
@@ -308,9 +338,15 @@ var _ CoreService = &CoreServiceMock{}
 //			PrivilegeCatalogFunc: func() []models.PrivilegeKey {
 //				panic("mock out the PrivilegeCatalog method")
 //			},
+//			RemovePrivilegeFromRoleFunc: func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+//				panic("mock out the RemovePrivilegeFromRole method")
+//			},
 //			RevokePrivilegeFunc: func(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error {
 //				panic("mock out the RevokePrivilege method")
 //			},
+//			RevokeRoleFromUserFunc: func(ctx context.Context, userID string, roleID string, revokerID string, reason string) error {
+//				panic("mock out the RevokeRoleFromUser method")
+//			},
 //			UpdateAllocationResourceMappingFunc: func(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error) {
 //				panic("mock out the UpdateAllocationResourceMapping method")
 //			},
@@ -350,6 +386,9 @@ var _ CoreService = &CoreServiceMock{}
 //			UpdateProjectStatusFunc: func(ctx context.Context, id string, status models.ProjectStatus) (*models.Project, error) {
 //				panic("mock out the UpdateProjectStatus method")
 //			},
+//			UpdateRoleFunc: func(ctx context.Context, roleID string, name string, description string, actorID string) (*models.Role, error) {
+//				panic("mock out the UpdateRole method")
+//			},
 //			UpdateUserFunc: func(ctx context.Context, user *models.User) error {
 //				panic("mock out the UpdateUser method")
 //			},
@@ -366,11 +405,14 @@ var _ CoreService = &CoreServiceMock{}
 //
 //	}
 type CoreServiceMock struct {
+	// AddPrivilegeToRoleFunc mocks the AddPrivilegeToRole method.
+	AddPrivilegeToRoleFunc func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
+
 	// AttachResourceToAllocationFunc mocks the AttachResourceToAllocation method.
 	AttachResourceToAllocationFunc func(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error)
 
-	// BootstrapPrivilegeGrantFunc mocks the BootstrapPrivilegeGrant method.
-	BootstrapPrivilegeGrantFunc func(ctx context.Context, email string, source string) error
+	// BootstrapSuperAdminFunc mocks the BootstrapSuperAdmin method.
+	BootstrapSuperAdminFunc func(ctx context.Context, email string, source string) error
 
 	// CreateAuditEventFunc mocks the CreateAuditEvent method.
 	CreateAuditEventFunc func(ctx context.Context, e *models.AuditEvent) (*models.AuditEvent, error)
@@ -414,6 +456,9 @@ type CoreServiceMock struct {
 	// CreateProjectFunc mocks the CreateProject method.
 	CreateProjectFunc func(ctx context.Context, project *models.Project) (*models.Project, error)
 
+	// CreateRoleFunc mocks the CreateRole method.
+	CreateRoleFunc func(ctx context.Context, name string, description string, actorID string) (*models.Role, error)
+
 	// CreateUserFunc mocks the CreateUser method.
 	CreateUserFunc func(ctx context.Context, user *models.User) (*models.User, error)
 
@@ -462,6 +507,9 @@ type CoreServiceMock struct {
 	// DeleteProjectFunc mocks the DeleteProject method.
 	DeleteProjectFunc func(ctx context.Context, id string) error
 
+	// DeleteRoleFunc mocks the DeleteRole method.
+	DeleteRoleFunc func(ctx context.Context, roleID string, actorID string) error
+
 	// DeleteUserFunc mocks the DeleteUser method.
 	DeleteUserFunc func(ctx context.Context, id string) error
 
@@ -471,6 +519,9 @@ type CoreServiceMock struct {
 	// DetachResourceFromAllocationFunc mocks the DetachResourceFromAllocation method.
 	DetachResourceFromAllocationFunc func(ctx context.Context, allocationID string, resourceID string) error
 
+	// EffectivePrivilegesFunc mocks the EffectivePrivileges method.
+	EffectivePrivilegesFunc func(ctx context.Context, userID string) ([]models.PrivilegeKey, error)
+
 	// GetAuditEventFunc mocks the GetAuditEvent method.
 	GetAuditEventFunc func(ctx context.Context, id string) (*models.AuditEvent, error)
 
@@ -537,6 +588,9 @@ type CoreServiceMock struct {
 	// GetProjectByOriginatedIDFunc mocks the GetProjectByOriginatedID method.
 	GetProjectByOriginatedIDFunc func(ctx context.Context, originatedID string) (*models.Project, error)
 
+	// GetRoleFunc mocks the GetRole method.
+	GetRoleFunc func(ctx context.Context, roleID string) (*models.Role, error)
+
 	// GetTotalSUUsageForAllocationFunc mocks the GetTotalSUUsageForAllocation method.
 	GetTotalSUUsageForAllocationFunc func(ctx context.Context, allocationID string) (int64, error)
 
@@ -564,6 +618,9 @@ type CoreServiceMock struct {
 	// GrantPrivilegeFunc mocks the GrantPrivilege method.
 	GrantPrivilegeFunc func(ctx context.Context, userID string, privilege models.PrivilegeKey, granterID string, reason string) (*models.UserPrivilege, error)
 
+	// GrantRoleToUserFunc mocks the GrantRoleToUser method.
+	GrantRoleToUserFunc func(ctx context.Context, userID string, roleID string, granterID string, reason string) (*models.UserRole, error)
+
 	// HasPrivilegeFunc mocks the HasPrivilege method.
 	HasPrivilegeFunc func(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error)
 
@@ -633,6 +690,15 @@ type CoreServiceMock struct {
 	// ListResourcesForAllocationFunc mocks the ListResourcesForAllocation method.
 	ListResourcesForAllocationFunc func(ctx context.Context, allocationID string) ([]models.ComputeAllocationResource, error)
 
+	// ListRoleHoldersFunc mocks the ListRoleHolders method.
+	ListRoleHoldersFunc func(ctx context.Context, roleID string) ([]models.UserRole, error)
+
+	// ListRolePrivilegesFunc mocks the ListRolePrivileges method.
+	ListRolePrivilegesFunc func(ctx context.Context, roleID string) ([]models.PrivilegeKey, error)
+
+	// ListRolesFunc mocks the ListRoles method.
+	ListRolesFunc func(ctx context.Context) ([]models.Role, error)
+
 	// ListUsagesByUserFunc mocks the ListUsagesByUser method.
 	ListUsagesByUserFunc func(ctx context.Context, userID string) ([]models.ComputeAllocationUsage, error)
 
@@ -645,6 +711,9 @@ type CoreServiceMock struct {
 	// ListUserPrivilegesFunc mocks the ListUserPrivileges method.
 	ListUserPrivilegesFunc func(ctx context.Context, userID string) ([]models.UserPrivilege, error)
 
+	// ListUserRolesFunc mocks the ListUserRoles method.
+	ListUserRolesFunc func(ctx context.Context, userID string) ([]models.UserRole, error)
+
 	// ListUsersByOrganizationFunc mocks the ListUsersByOrganization method.
 	ListUsersByOrganizationFunc func(ctx context.Context, organizationID string) ([]models.User, error)
 
@@ -654,9 +723,15 @@ type CoreServiceMock struct {
 	// PrivilegeCatalogFunc mocks the PrivilegeCatalog method.
 	PrivilegeCatalogFunc func() []models.PrivilegeKey
 
+	// RemovePrivilegeFromRoleFunc mocks the RemovePrivilegeFromRole method.
+	RemovePrivilegeFromRoleFunc func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
+
 	// RevokePrivilegeFunc mocks the RevokePrivilege method.
 	RevokePrivilegeFunc func(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error
 
+	// RevokeRoleFromUserFunc mocks the RevokeRoleFromUser method.
+	RevokeRoleFromUserFunc func(ctx context.Context, userID string, roleID string, revokerID string, reason string) error
+
 	// UpdateAllocationResourceMappingFunc mocks the UpdateAllocationResourceMapping method.
 	UpdateAllocationResourceMappingFunc func(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error)
 
@@ -696,6 +771,9 @@ type CoreServiceMock struct {
 	// UpdateProjectStatusFunc mocks the UpdateProjectStatus method.
 	UpdateProjectStatusFunc func(ctx context.Context, id string, status models.ProjectStatus) (*models.Project, error)
 
+	// UpdateRoleFunc mocks the UpdateRole method.
+	UpdateRoleFunc func(ctx context.Context, roleID string, name string, description string, actorID string) (*models.Role, error)
+
 	// UpdateUserFunc mocks the UpdateUser method.
 	UpdateUserFunc func(ctx context.Context, user *models.User) error
 
@@ -707,6 +785,17 @@ type CoreServiceMock struct {
 
 	// calls tracks calls to the methods.
 	calls struct {
+		// AddPrivilegeToRole holds details about calls to the AddPrivilegeToRole method.
+		AddPrivilegeToRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+			// Privilege is the privilege argument value.
+			Privilege models.PrivilegeKey
+			// ActorID is the actorID argument value.
+			ActorID string
+		}
 		// AttachResourceToAllocation holds details about calls to the AttachResourceToAllocation method.
 		AttachResourceToAllocation []struct {
 			// Ctx is the ctx argument value.
@@ -720,8 +809,8 @@ type CoreServiceMock struct {
 			// ResourceTime is the resourceTime argument value.
 			ResourceTime int64
 		}
-		// BootstrapPrivilegeGrant holds details about calls to the BootstrapPrivilegeGrant method.
-		BootstrapPrivilegeGrant []struct {
+		// BootstrapSuperAdmin holds details about calls to the BootstrapSuperAdmin method.
+		BootstrapSuperAdmin []struct {
 			// Ctx is the ctx argument value.
 			Ctx context.Context
 			// Email is the email argument value.
@@ -827,6 +916,17 @@ type CoreServiceMock struct {
 			// Project is the project argument value.
 			Project *models.Project
 		}
+		// CreateRole holds details about calls to the CreateRole method.
+		CreateRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// Name is the name argument value.
+			Name string
+			// Description is the description argument value.
+			Description string
+			// ActorID is the actorID argument value.
+			ActorID string
+		}
 		// CreateUser holds details about calls to the CreateUser method.
 		CreateUser []struct {
 			// Ctx is the ctx argument value.
@@ -939,6 +1039,15 @@ type CoreServiceMock struct {
 			// ID is the id argument value.
 			ID string
 		}
+		// DeleteRole holds details about calls to the DeleteRole method.
+		DeleteRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+			// ActorID is the actorID argument value.
+			ActorID string
+		}
 		// DeleteUser holds details about calls to the DeleteUser method.
 		DeleteUser []struct {
 			// Ctx is the ctx argument value.
@@ -962,6 +1071,13 @@ type CoreServiceMock struct {
 			// ResourceID is the resourceID argument value.
 			ResourceID string
 		}
+		// EffectivePrivileges holds details about calls to the EffectivePrivileges method.
+		EffectivePrivileges []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// GetAuditEvent holds details about calls to the GetAuditEvent method.
 		GetAuditEvent []struct {
 			// Ctx is the ctx argument value.
@@ -1122,6 +1238,13 @@ type CoreServiceMock struct {
 			// OriginatedID is the originatedID argument value.
 			OriginatedID string
 		}
+		// GetRole holds details about calls to the GetRole method.
+		GetRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+		}
 		// GetTotalSUUsageForAllocation holds details about calls to the GetTotalSUUsageForAllocation method.
 		GetTotalSUUsageForAllocation []struct {
 			// Ctx is the ctx argument value.
@@ -1197,6 +1320,19 @@ type CoreServiceMock struct {
 			// Reason is the reason argument value.
 			Reason string
 		}
+		// GrantRoleToUser holds details about calls to the GrantRoleToUser method.
+		GrantRoleToUser []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+			// RoleID is the roleID argument value.
+			RoleID string
+			// GranterID is the granterID argument value.
+			GranterID string
+			// Reason is the reason argument value.
+			Reason string
+		}
 		// HasPrivilege holds details about calls to the HasPrivilege method.
 		HasPrivilege []struct {
 			// Ctx is the ctx argument value.
@@ -1354,6 +1490,25 @@ type CoreServiceMock struct {
 			// AllocationID is the allocationID argument value.
 			AllocationID string
 		}
+		// ListRoleHolders holds details about calls to the ListRoleHolders method.
+		ListRoleHolders []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+		}
+		// ListRolePrivileges holds details about calls to the ListRolePrivileges method.
+		ListRolePrivileges []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+		}
+		// ListRoles holds details about calls to the ListRoles method.
+		ListRoles []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+		}
 		// ListUsagesByUser holds details about calls to the ListUsagesByUser method.
 		ListUsagesByUser []struct {
 			// Ctx is the ctx argument value.
@@ -1382,6 +1537,13 @@ type CoreServiceMock struct {
 			// UserID is the userID argument value.
 			UserID string
 		}
+		// ListUserRoles holds details about calls to the ListUserRoles method.
+		ListUserRoles []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+		}
 		// ListUsersByOrganization holds details about calls to the ListUsersByOrganization method.
 		ListUsersByOrganization []struct {
 			// Ctx is the ctx argument value.
@@ -1401,6 +1563,17 @@ type CoreServiceMock struct {
 		// PrivilegeCatalog holds details about calls to the PrivilegeCatalog method.
 		PrivilegeCatalog []struct {
 		}
+		// RemovePrivilegeFromRole holds details about calls to the RemovePrivilegeFromRole method.
+		RemovePrivilegeFromRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+			// Privilege is the privilege argument value.
+			Privilege models.PrivilegeKey
+			// ActorID is the actorID argument value.
+			ActorID string
+		}
 		// RevokePrivilege holds details about calls to the RevokePrivilege method.
 		RevokePrivilege []struct {
 			// Ctx is the ctx argument value.
@@ -1414,6 +1587,19 @@ type CoreServiceMock struct {
 			// Reason is the reason argument value.
 			Reason string
 		}
+		// RevokeRoleFromUser holds details about calls to the RevokeRoleFromUser method.
+		RevokeRoleFromUser []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// UserID is the userID argument value.
+			UserID string
+			// RoleID is the roleID argument value.
+			RoleID string
+			// RevokerID is the revokerID argument value.
+			RevokerID string
+			// Reason is the reason argument value.
+			Reason string
+		}
 		// UpdateAllocationResourceMapping holds details about calls to the UpdateAllocationResourceMapping method.
 		UpdateAllocationResourceMapping []struct {
 			// Ctx is the ctx argument value.
@@ -1515,6 +1701,19 @@ type CoreServiceMock struct {
 			// Status is the status argument value.
 			Status models.ProjectStatus
 		}
+		// UpdateRole holds details about calls to the UpdateRole method.
+		UpdateRole []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// RoleID is the roleID argument value.
+			RoleID string
+			// Name is the name argument value.
+			Name string
+			// Description is the description argument value.
+			Description string
+			// ActorID is the actorID argument value.
+			ActorID string
+		}
 		// UpdateUser holds details about calls to the UpdateUser method.
 		UpdateUser []struct {
 			// Ctx is the ctx argument value.
@@ -1539,8 +1738,9 @@ type CoreServiceMock struct {
 			Status models.UserStatus
 		}
 	}
+	lockAddPrivilegeToRole                                   sync.RWMutex
 	lockAttachResourceToAllocation                           sync.RWMutex
-	lockBootstrapPrivilegeGrant                              sync.RWMutex
+	lockBootstrapSuperAdmin                                  sync.RWMutex
 	lockCreateAuditEvent                                     sync.RWMutex
 	lockCreateComputeAllocation                              sync.RWMutex
 	lockCreateComputeAllocationChangeRequest                 sync.RWMutex
@@ -1555,6 +1755,7 @@ type CoreServiceMock struct {
 	lockCreateComputeClusterUser                             sync.RWMutex
 	lockCreateOrganization                                   sync.RWMutex
 	lockCreateProject                                        sync.RWMutex
+	lockCreateRole                                           sync.RWMutex
 	lockCreateUser                                           sync.RWMutex
 	lockCreateUserIdentity                                   sync.RWMutex
 	lockDeleteAuditEvent                                     sync.RWMutex
@@ -1571,9 +1772,11 @@ type CoreServiceMock struct {
 	lockDeleteComputeClusterUser                             sync.RWMutex
 	lockDeleteOrganization                                   sync.RWMutex
 	lockDeleteProject                                        sync.RWMutex
+	lockDeleteRole                                           sync.RWMutex
 	lockDeleteUser                                           sync.RWMutex
 	lockDeleteUserIdentity                                   sync.RWMutex
 	lockDetachResourceFromAllocation                         sync.RWMutex
+	lockEffectivePrivileges                                  sync.RWMutex
 	lockGetAuditEvent                                        sync.RWMutex
 	lockGetComputeAllocation                                 sync.RWMutex
 	lockGetComputeAllocationChangeRequest                    sync.RWMutex
@@ -1596,6 +1799,7 @@ type CoreServiceMock struct {
 	lockGetOrganizationByOriginatedID                        sync.RWMutex
 	lockGetProject                                           sync.RWMutex
 	lockGetProjectByOriginatedID                             sync.RWMutex
+	lockGetRole                                              sync.RWMutex
 	lockGetTotalSUUsageForAllocation                         sync.RWMutex
 	lockGetTotalSUUsageForUserInAllocation                   sync.RWMutex
 	lockGetUser                                              sync.RWMutex
@@ -1605,6 +1809,7 @@ type CoreServiceMock struct {
 	lockGetUserIdentityByOIDCSub                             sync.RWMutex
 	lockGetUserIdentityBySourceAndExternalID                 sync.RWMutex
 	lockGrantPrivilege                                       sync.RWMutex
+	lockGrantRoleToUser                                      sync.RWMutex
 	lockHasPrivilege                                         sync.RWMutex
 	lockListAllAuditEvents                                   sync.RWMutex
 	lockListAllocationsForResource                           sync.RWMutex
@@ -1628,14 +1833,20 @@ type CoreServiceMock struct {
 	lockListProjectsByPI                                     sync.RWMutex
 	lockListRatesForResource                                 sync.RWMutex
 	lockListResourcesForAllocation                           sync.RWMutex
+	lockListRoleHolders                                      sync.RWMutex
+	lockListRolePrivileges                                   sync.RWMutex
+	lockListRoles                                            sync.RWMutex
 	lockListUsagesByUser                                     sync.RWMutex
 	lockListUsagesForAllocation                              sync.RWMutex
 	lockListUserIdentitiesForUser                            sync.RWMutex
 	lockListUserPrivileges                                   sync.RWMutex
+	lockListUserRoles                                        sync.RWMutex
 	lockListUsersByOrganization                              sync.RWMutex
 	lockMergeUsers                                           sync.RWMutex
 	lockPrivilegeCatalog                                     sync.RWMutex
+	lockRemovePrivilegeFromRole                              sync.RWMutex
 	lockRevokePrivilege                                      sync.RWMutex
+	lockRevokeRoleFromUser                                   sync.RWMutex
 	lockUpdateAllocationResourceMapping                      sync.RWMutex
 	lockUpdateComputeAllocation                              sync.RWMutex
 	lockUpdateComputeAllocationChangeRequest                 sync.RWMutex
@@ -1649,11 +1860,56 @@ type CoreServiceMock struct {
 	lockUpdateOrganization                                   sync.RWMutex
 	lockUpdateProject                                        sync.RWMutex
 	lockUpdateProjectStatus                                  sync.RWMutex
+	lockUpdateRole                                           sync.RWMutex
 	lockUpdateUser                                           sync.RWMutex
 	lockUpdateUserIdentity                                   sync.RWMutex
 	lockUpdateUserStatus                                     sync.RWMutex
 }
 
+// AddPrivilegeToRole calls AddPrivilegeToRoleFunc.
+func (mock *CoreServiceMock) AddPrivilegeToRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+	if mock.AddPrivilegeToRoleFunc == nil {
+		panic("CoreServiceMock.AddPrivilegeToRoleFunc: method is nil but CoreService.AddPrivilegeToRole was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		RoleID    string
+		Privilege models.PrivilegeKey
+		ActorID   string
+	}{
+		Ctx:       ctx,
+		RoleID:    roleID,
+		Privilege: privilege,
+		ActorID:   actorID,
+	}
+	mock.lockAddPrivilegeToRole.Lock()
+	mock.calls.AddPrivilegeToRole = append(mock.calls.AddPrivilegeToRole, callInfo)
+	mock.lockAddPrivilegeToRole.Unlock()
+	return mock.AddPrivilegeToRoleFunc(ctx, roleID, privilege, actorID)
+}
+
+// AddPrivilegeToRoleCalls gets all the calls that were made to AddPrivilegeToRole.
+// Check the length with:
+//
+//	len(mockedCoreService.AddPrivilegeToRoleCalls())
+func (mock *CoreServiceMock) AddPrivilegeToRoleCalls() []struct {
+	Ctx       context.Context
+	RoleID    string
+	Privilege models.PrivilegeKey
+	ActorID   string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		RoleID    string
+		Privilege models.PrivilegeKey
+		ActorID   string
+	}
+	mock.lockAddPrivilegeToRole.RLock()
+	calls = mock.calls.AddPrivilegeToRole
+	mock.lockAddPrivilegeToRole.RUnlock()
+	return calls
+}
+
 // AttachResourceToAllocation calls AttachResourceToAllocationFunc.
 func (mock *CoreServiceMock) AttachResourceToAllocation(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error) {
 	if mock.AttachResourceToAllocationFunc == nil {
@@ -1702,10 +1958,10 @@ func (mock *CoreServiceMock) AttachResourceToAllocationCalls() []struct {
 	return calls
 }
 
-// BootstrapPrivilegeGrant calls BootstrapPrivilegeGrantFunc.
-func (mock *CoreServiceMock) BootstrapPrivilegeGrant(ctx context.Context, email string, source string) error {
-	if mock.BootstrapPrivilegeGrantFunc == nil {
-		panic("CoreServiceMock.BootstrapPrivilegeGrantFunc: method is nil but CoreService.BootstrapPrivilegeGrant was just called")
+// BootstrapSuperAdmin calls BootstrapSuperAdminFunc.
+func (mock *CoreServiceMock) BootstrapSuperAdmin(ctx context.Context, email string, source string) error {
+	if mock.BootstrapSuperAdminFunc == nil {
+		panic("CoreServiceMock.BootstrapSuperAdminFunc: method is nil but CoreService.BootstrapSuperAdmin was just called")
 	}
 	callInfo := struct {
 		Ctx    context.Context
@@ -1716,17 +1972,17 @@ func (mock *CoreServiceMock) BootstrapPrivilegeGrant(ctx context.Context, email
 		Email:  email,
 		Source: source,
 	}
-	mock.lockBootstrapPrivilegeGrant.Lock()
-	mock.calls.BootstrapPrivilegeGrant = append(mock.calls.BootstrapPrivilegeGrant, callInfo)
-	mock.lockBootstrapPrivilegeGrant.Unlock()
-	return mock.BootstrapPrivilegeGrantFunc(ctx, email, source)
+	mock.lockBootstrapSuperAdmin.Lock()
+	mock.calls.BootstrapSuperAdmin = append(mock.calls.BootstrapSuperAdmin, callInfo)
+	mock.lockBootstrapSuperAdmin.Unlock()
+	return mock.BootstrapSuperAdminFunc(ctx, email, source)
 }
 
-// BootstrapPrivilegeGrantCalls gets all the calls that were made to BootstrapPrivilegeGrant.
+// BootstrapSuperAdminCalls gets all the calls that were made to BootstrapSuperAdmin.
 // Check the length with:
 //
-//	len(mockedCoreService.BootstrapPrivilegeGrantCalls())
-func (mock *CoreServiceMock) BootstrapPrivilegeGrantCalls() []struct {
+//	len(mockedCoreService.BootstrapSuperAdminCalls())
+func (mock *CoreServiceMock) BootstrapSuperAdminCalls() []struct {
 	Ctx    context.Context
 	Email  string
 	Source string
@@ -1736,9 +1992,9 @@ func (mock *CoreServiceMock) BootstrapPrivilegeGrantCalls() []struct {
 		Email  string
 		Source string
 	}
-	mock.lockBootstrapPrivilegeGrant.RLock()
-	calls = mock.calls.BootstrapPrivilegeGrant
-	mock.lockBootstrapPrivilegeGrant.RUnlock()
+	mock.lockBootstrapSuperAdmin.RLock()
+	calls = mock.calls.BootstrapSuperAdmin
+	mock.lockBootstrapSuperAdmin.RUnlock()
 	return calls
 }
 
@@ -2246,6 +2502,50 @@ func (mock *CoreServiceMock) CreateProjectCalls() []struct {
 	return calls
 }
 
+// CreateRole calls CreateRoleFunc.
+func (mock *CoreServiceMock) CreateRole(ctx context.Context, name string, description string, actorID string) (*models.Role, error) {
+	if mock.CreateRoleFunc == nil {
+		panic("CoreServiceMock.CreateRoleFunc: method is nil but CoreService.CreateRole was just called")
+	}
+	callInfo := struct {
+		Ctx         context.Context
+		Name        string
+		Description string
+		ActorID     string
+	}{
+		Ctx:         ctx,
+		Name:        name,
+		Description: description,
+		ActorID:     actorID,
+	}
+	mock.lockCreateRole.Lock()
+	mock.calls.CreateRole = append(mock.calls.CreateRole, callInfo)
+	mock.lockCreateRole.Unlock()
+	return mock.CreateRoleFunc(ctx, name, description, actorID)
+}
+
+// CreateRoleCalls gets all the calls that were made to CreateRole.
+// Check the length with:
+//
+//	len(mockedCoreService.CreateRoleCalls())
+func (mock *CoreServiceMock) CreateRoleCalls() []struct {
+	Ctx         context.Context
+	Name        string
+	Description string
+	ActorID     string
+} {
+	var calls []struct {
+		Ctx         context.Context
+		Name        string
+		Description string
+		ActorID     string
+	}
+	mock.lockCreateRole.RLock()
+	calls = mock.calls.CreateRole
+	mock.lockCreateRole.RUnlock()
+	return calls
+}
+
 // CreateUser calls CreateUserFunc.
 func (mock *CoreServiceMock) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
 	if mock.CreateUserFunc == nil {
@@ -2822,6 +3122,46 @@ func (mock *CoreServiceMock) DeleteProjectCalls() []struct {
 	return calls
 }
 
+// DeleteRole calls DeleteRoleFunc.
+func (mock *CoreServiceMock) DeleteRole(ctx context.Context, roleID string, actorID string) error {
+	if mock.DeleteRoleFunc == nil {
+		panic("CoreServiceMock.DeleteRoleFunc: method is nil but CoreService.DeleteRole was just called")
+	}
+	callInfo := struct {
+		Ctx     context.Context
+		RoleID  string
+		ActorID string
+	}{
+		Ctx:     ctx,
+		RoleID:  roleID,
+		ActorID: actorID,
+	}
+	mock.lockDeleteRole.Lock()
+	mock.calls.DeleteRole = append(mock.calls.DeleteRole, callInfo)
+	mock.lockDeleteRole.Unlock()
+	return mock.DeleteRoleFunc(ctx, roleID, actorID)
+}
+
+// DeleteRoleCalls gets all the calls that were made to DeleteRole.
+// Check the length with:
+//
+//	len(mockedCoreService.DeleteRoleCalls())
+func (mock *CoreServiceMock) DeleteRoleCalls() []struct {
+	Ctx     context.Context
+	RoleID  string
+	ActorID string
+} {
+	var calls []struct {
+		Ctx     context.Context
+		RoleID  string
+		ActorID string
+	}
+	mock.lockDeleteRole.RLock()
+	calls = mock.calls.DeleteRole
+	mock.lockDeleteRole.RUnlock()
+	return calls
+}
+
 // DeleteUser calls DeleteUserFunc.
 func (mock *CoreServiceMock) DeleteUser(ctx context.Context, id string) error {
 	if mock.DeleteUserFunc == nil {
@@ -2934,6 +3274,42 @@ func (mock *CoreServiceMock) DetachResourceFromAllocationCalls() []struct {
 	return calls
 }
 
+// EffectivePrivileges calls EffectivePrivilegesFunc.
+func (mock *CoreServiceMock) EffectivePrivileges(ctx context.Context, userID string) ([]models.PrivilegeKey, error) {
+	if mock.EffectivePrivilegesFunc == nil {
+		panic("CoreServiceMock.EffectivePrivilegesFunc: method is nil but CoreService.EffectivePrivileges was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		UserID string
+	}{
+		Ctx:    ctx,
+		UserID: userID,
+	}
+	mock.lockEffectivePrivileges.Lock()
+	mock.calls.EffectivePrivileges = append(mock.calls.EffectivePrivileges, callInfo)
+	mock.lockEffectivePrivileges.Unlock()
+	return mock.EffectivePrivilegesFunc(ctx, userID)
+}
+
+// EffectivePrivilegesCalls gets all the calls that were made to EffectivePrivileges.
+// Check the length with:
+//
+//	len(mockedCoreService.EffectivePrivilegesCalls())
+func (mock *CoreServiceMock) EffectivePrivilegesCalls() []struct {
+	Ctx    context.Context
+	UserID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		UserID string
+	}
+	mock.lockEffectivePrivileges.RLock()
+	calls = mock.calls.EffectivePrivileges
+	mock.lockEffectivePrivileges.RUnlock()
+	return calls
+}
+
 // GetAuditEvent calls GetAuditEventFunc.
 func (mock *CoreServiceMock) GetAuditEvent(ctx context.Context, id string) (*models.AuditEvent, error) {
 	if mock.GetAuditEventFunc == nil {
@@ -3738,6 +4114,42 @@ func (mock *CoreServiceMock) GetProjectByOriginatedIDCalls() []struct {
 	return calls
 }
 
+// GetRole calls GetRoleFunc.
+func (mock *CoreServiceMock) GetRole(ctx context.Context, roleID string) (*models.Role, error) {
+	if mock.GetRoleFunc == nil {
+		panic("CoreServiceMock.GetRoleFunc: method is nil but CoreService.GetRole was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		RoleID string
+	}{
+		Ctx:    ctx,
+		RoleID: roleID,
+	}
+	mock.lockGetRole.Lock()
+	mock.calls.GetRole = append(mock.calls.GetRole, callInfo)
+	mock.lockGetRole.Unlock()
+	return mock.GetRoleFunc(ctx, roleID)
+}
+
+// GetRoleCalls gets all the calls that were made to GetRole.
+// Check the length with:
+//
+//	len(mockedCoreService.GetRoleCalls())
+func (mock *CoreServiceMock) GetRoleCalls() []struct {
+	Ctx    context.Context
+	RoleID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		RoleID string
+	}
+	mock.lockGetRole.RLock()
+	calls = mock.calls.GetRole
+	mock.lockGetRole.RUnlock()
+	return calls
+}
+
 // GetTotalSUUsageForAllocation calls GetTotalSUUsageForAllocationFunc.
 func (mock *CoreServiceMock) GetTotalSUUsageForAllocation(ctx context.Context, allocationID string) (int64, error) {
 	if mock.GetTotalSUUsageForAllocationFunc == nil {
@@ -4086,6 +4498,54 @@ func (mock *CoreServiceMock) GrantPrivilegeCalls() []struct {
 	return calls
 }
 
+// GrantRoleToUser calls GrantRoleToUserFunc.
+func (mock *CoreServiceMock) GrantRoleToUser(ctx context.Context, userID string, roleID string, granterID string, reason string) (*models.UserRole, error) {
+	if mock.GrantRoleToUserFunc == nil {
+		panic("CoreServiceMock.GrantRoleToUserFunc: method is nil but CoreService.GrantRoleToUser was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		UserID    string
+		RoleID    string
+		GranterID string
+		Reason    string
+	}{
+		Ctx:       ctx,
+		UserID:    userID,
+		RoleID:    roleID,
+		GranterID: granterID,
+		Reason:    reason,
+	}
+	mock.lockGrantRoleToUser.Lock()
+	mock.calls.GrantRoleToUser = append(mock.calls.GrantRoleToUser, callInfo)
+	mock.lockGrantRoleToUser.Unlock()
+	return mock.GrantRoleToUserFunc(ctx, userID, roleID, granterID, reason)
+}
+
+// GrantRoleToUserCalls gets all the calls that were made to GrantRoleToUser.
+// Check the length with:
+//
+//	len(mockedCoreService.GrantRoleToUserCalls())
+func (mock *CoreServiceMock) GrantRoleToUserCalls() []struct {
+	Ctx       context.Context
+	UserID    string
+	RoleID    string
+	GranterID string
+	Reason    string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		UserID    string
+		RoleID    string
+		GranterID string
+		Reason    string
+	}
+	mock.lockGrantRoleToUser.RLock()
+	calls = mock.calls.GrantRoleToUser
+	mock.lockGrantRoleToUser.RUnlock()
+	return calls
+}
+
 // HasPrivilege calls HasPrivilegeFunc.
 func (mock *CoreServiceMock) HasPrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error) {
 	if mock.HasPrivilegeFunc == nil {
@@ -4906,6 +5366,110 @@ func (mock *CoreServiceMock) ListResourcesForAllocationCalls() []struct {
 	return calls
 }
 
+// ListRoleHolders calls ListRoleHoldersFunc.
+func (mock *CoreServiceMock) ListRoleHolders(ctx context.Context, roleID string) ([]models.UserRole, error) {
+	if mock.ListRoleHoldersFunc == nil {
+		panic("CoreServiceMock.ListRoleHoldersFunc: method is nil but CoreService.ListRoleHolders was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		RoleID string
+	}{
+		Ctx:    ctx,
+		RoleID: roleID,
+	}
+	mock.lockListRoleHolders.Lock()
+	mock.calls.ListRoleHolders = append(mock.calls.ListRoleHolders, callInfo)
+	mock.lockListRoleHolders.Unlock()
+	return mock.ListRoleHoldersFunc(ctx, roleID)
+}
+
+// ListRoleHoldersCalls gets all the calls that were made to ListRoleHolders.
+// Check the length with:
+//
+//	len(mockedCoreService.ListRoleHoldersCalls())
+func (mock *CoreServiceMock) ListRoleHoldersCalls() []struct {
+	Ctx    context.Context
+	RoleID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		RoleID string
+	}
+	mock.lockListRoleHolders.RLock()
+	calls = mock.calls.ListRoleHolders
+	mock.lockListRoleHolders.RUnlock()
+	return calls
+}
+
+// ListRolePrivileges calls ListRolePrivilegesFunc.
+func (mock *CoreServiceMock) ListRolePrivileges(ctx context.Context, roleID string) ([]models.PrivilegeKey, error) {
+	if mock.ListRolePrivilegesFunc == nil {
+		panic("CoreServiceMock.ListRolePrivilegesFunc: method is nil but CoreService.ListRolePrivileges was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		RoleID string
+	}{
+		Ctx:    ctx,
+		RoleID: roleID,
+	}
+	mock.lockListRolePrivileges.Lock()
+	mock.calls.ListRolePrivileges = append(mock.calls.ListRolePrivileges, callInfo)
+	mock.lockListRolePrivileges.Unlock()
+	return mock.ListRolePrivilegesFunc(ctx, roleID)
+}
+
+// ListRolePrivilegesCalls gets all the calls that were made to ListRolePrivileges.
+// Check the length with:
+//
+//	len(mockedCoreService.ListRolePrivilegesCalls())
+func (mock *CoreServiceMock) ListRolePrivilegesCalls() []struct {
+	Ctx    context.Context
+	RoleID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		RoleID string
+	}
+	mock.lockListRolePrivileges.RLock()
+	calls = mock.calls.ListRolePrivileges
+	mock.lockListRolePrivileges.RUnlock()
+	return calls
+}
+
+// ListRoles calls ListRolesFunc.
+func (mock *CoreServiceMock) ListRoles(ctx context.Context) ([]models.Role, error) {
+	if mock.ListRolesFunc == nil {
+		panic("CoreServiceMock.ListRolesFunc: method is nil but CoreService.ListRoles was just called")
+	}
+	callInfo := struct {
+		Ctx context.Context
+	}{
+		Ctx: ctx,
+	}
+	mock.lockListRoles.Lock()
+	mock.calls.ListRoles = append(mock.calls.ListRoles, callInfo)
+	mock.lockListRoles.Unlock()
+	return mock.ListRolesFunc(ctx)
+}
+
+// ListRolesCalls gets all the calls that were made to ListRoles.
+// Check the length with:
+//
+//	len(mockedCoreService.ListRolesCalls())
+func (mock *CoreServiceMock) ListRolesCalls() []struct {
+	Ctx context.Context
+} {
+	var calls []struct {
+		Ctx context.Context
+	}
+	mock.lockListRoles.RLock()
+	calls = mock.calls.ListRoles
+	mock.lockListRoles.RUnlock()
+	return calls
+}
+
 // ListUsagesByUser calls ListUsagesByUserFunc.
 func (mock *CoreServiceMock) ListUsagesByUser(ctx context.Context, userID string) ([]models.ComputeAllocationUsage, error) {
 	if mock.ListUsagesByUserFunc == nil {
@@ -5050,6 +5614,42 @@ func (mock *CoreServiceMock) ListUserPrivilegesCalls() []struct {
 	return calls
 }
 
+// ListUserRoles calls ListUserRolesFunc.
+func (mock *CoreServiceMock) ListUserRoles(ctx context.Context, userID string) ([]models.UserRole, error) {
+	if mock.ListUserRolesFunc == nil {
+		panic("CoreServiceMock.ListUserRolesFunc: method is nil but CoreService.ListUserRoles was just called")
+	}
+	callInfo := struct {
+		Ctx    context.Context
+		UserID string
+	}{
+		Ctx:    ctx,
+		UserID: userID,
+	}
+	mock.lockListUserRoles.Lock()
+	mock.calls.ListUserRoles = append(mock.calls.ListUserRoles, callInfo)
+	mock.lockListUserRoles.Unlock()
+	return mock.ListUserRolesFunc(ctx, userID)
+}
+
+// ListUserRolesCalls gets all the calls that were made to ListUserRoles.
+// Check the length with:
+//
+//	len(mockedCoreService.ListUserRolesCalls())
+func (mock *CoreServiceMock) ListUserRolesCalls() []struct {
+	Ctx    context.Context
+	UserID string
+} {
+	var calls []struct {
+		Ctx    context.Context
+		UserID string
+	}
+	mock.lockListUserRoles.RLock()
+	calls = mock.calls.ListUserRoles
+	mock.lockListUserRoles.RUnlock()
+	return calls
+}
+
 // ListUsersByOrganization calls ListUsersByOrganizationFunc.
 func (mock *CoreServiceMock) ListUsersByOrganization(ctx context.Context, organizationID string) ([]models.User, error) {
 	if mock.ListUsersByOrganizationFunc == nil {
@@ -5153,6 +5753,50 @@ func (mock *CoreServiceMock) PrivilegeCatalogCalls() []struct {
 	return calls
 }
 
+// RemovePrivilegeFromRole calls RemovePrivilegeFromRoleFunc.
+func (mock *CoreServiceMock) RemovePrivilegeFromRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+	if mock.RemovePrivilegeFromRoleFunc == nil {
+		panic("CoreServiceMock.RemovePrivilegeFromRoleFunc: method is nil but CoreService.RemovePrivilegeFromRole was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		RoleID    string
+		Privilege models.PrivilegeKey
+		ActorID   string
+	}{
+		Ctx:       ctx,
+		RoleID:    roleID,
+		Privilege: privilege,
+		ActorID:   actorID,
+	}
+	mock.lockRemovePrivilegeFromRole.Lock()
+	mock.calls.RemovePrivilegeFromRole = append(mock.calls.RemovePrivilegeFromRole, callInfo)
+	mock.lockRemovePrivilegeFromRole.Unlock()
+	return mock.RemovePrivilegeFromRoleFunc(ctx, roleID, privilege, actorID)
+}
+
+// RemovePrivilegeFromRoleCalls gets all the calls that were made to RemovePrivilegeFromRole.
+// Check the length with:
+//
+//	len(mockedCoreService.RemovePrivilegeFromRoleCalls())
+func (mock *CoreServiceMock) RemovePrivilegeFromRoleCalls() []struct {
+	Ctx       context.Context
+	RoleID    string
+	Privilege models.PrivilegeKey
+	ActorID   string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		RoleID    string
+		Privilege models.PrivilegeKey
+		ActorID   string
+	}
+	mock.lockRemovePrivilegeFromRole.RLock()
+	calls = mock.calls.RemovePrivilegeFromRole
+	mock.lockRemovePrivilegeFromRole.RUnlock()
+	return calls
+}
+
 // RevokePrivilege calls RevokePrivilegeFunc.
 func (mock *CoreServiceMock) RevokePrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error {
 	if mock.RevokePrivilegeFunc == nil {
@@ -5201,6 +5845,54 @@ func (mock *CoreServiceMock) RevokePrivilegeCalls() []struct {
 	return calls
 }
 
+// RevokeRoleFromUser calls RevokeRoleFromUserFunc.
+func (mock *CoreServiceMock) RevokeRoleFromUser(ctx context.Context, userID string, roleID string, revokerID string, reason string) error {
+	if mock.RevokeRoleFromUserFunc == nil {
+		panic("CoreServiceMock.RevokeRoleFromUserFunc: method is nil but CoreService.RevokeRoleFromUser was just called")
+	}
+	callInfo := struct {
+		Ctx       context.Context
+		UserID    string
+		RoleID    string
+		RevokerID string
+		Reason    string
+	}{
+		Ctx:       ctx,
+		UserID:    userID,
+		RoleID:    roleID,
+		RevokerID: revokerID,
+		Reason:    reason,
+	}
+	mock.lockRevokeRoleFromUser.Lock()
+	mock.calls.RevokeRoleFromUser = append(mock.calls.RevokeRoleFromUser, callInfo)
+	mock.lockRevokeRoleFromUser.Unlock()
+	return mock.RevokeRoleFromUserFunc(ctx, userID, roleID, revokerID, reason)
+}
+
+// RevokeRoleFromUserCalls gets all the calls that were made to RevokeRoleFromUser.
+// Check the length with:
+//
+//	len(mockedCoreService.RevokeRoleFromUserCalls())
+func (mock *CoreServiceMock) RevokeRoleFromUserCalls() []struct {
+	Ctx       context.Context
+	UserID    string
+	RoleID    string
+	RevokerID string
+	Reason    string
+} {
+	var calls []struct {
+		Ctx       context.Context
+		UserID    string
+		RoleID    string
+		RevokerID string
+		Reason    string
+	}
+	mock.lockRevokeRoleFromUser.RLock()
+	calls = mock.calls.RevokeRoleFromUser
+	mock.lockRevokeRoleFromUser.RUnlock()
+	return calls
+}
+
 // UpdateAllocationResourceMapping calls UpdateAllocationResourceMappingFunc.
 func (mock *CoreServiceMock) UpdateAllocationResourceMapping(ctx context.Context, allocationID string, resourceID string, resourceAmount int64, resourceTime int64) (*models.ComputeAllocationResourceMapping, error) {
 	if mock.UpdateAllocationResourceMappingFunc == nil {
@@ -5689,6 +6381,54 @@ func (mock *CoreServiceMock) UpdateProjectStatusCalls() []struct {
 	return calls
 }
 
+// UpdateRole calls UpdateRoleFunc.
+func (mock *CoreServiceMock) UpdateRole(ctx context.Context, roleID string, name string, description string, actorID string) (*models.Role, error) {
+	if mock.UpdateRoleFunc == nil {
+		panic("CoreServiceMock.UpdateRoleFunc: method is nil but CoreService.UpdateRole was just called")
+	}
+	callInfo := struct {
+		Ctx         context.Context
+		RoleID      string
+		Name        string
+		Description string
+		ActorID     string
+	}{
+		Ctx:         ctx,
+		RoleID:      roleID,
+		Name:        name,
+		Description: description,
+		ActorID:     actorID,
+	}
+	mock.lockUpdateRole.Lock()
+	mock.calls.UpdateRole = append(mock.calls.UpdateRole, callInfo)
+	mock.lockUpdateRole.Unlock()
+	return mock.UpdateRoleFunc(ctx, roleID, name, description, actorID)
+}
+
+// UpdateRoleCalls gets all the calls that were made to UpdateRole.
+// Check the length with:
+//
+//	len(mockedCoreService.UpdateRoleCalls())
+func (mock *CoreServiceMock) UpdateRoleCalls() []struct {
+	Ctx         context.Context
+	RoleID      string
+	Name        string
+	Description string
+	ActorID     string
+} {
+	var calls []struct {
+		Ctx         context.Context
+		RoleID      string
+		Name        string
+		Description string
+		ActorID     string
+	}
+	mock.lockUpdateRole.RLock()
+	calls = mock.calls.UpdateRole
+	mock.lockUpdateRole.RUnlock()
+	return calls
+}
+
 // UpdateUser calls UpdateUserFunc.
 func (mock *CoreServiceMock) UpdateUser(ctx context.Context, user *models.User) error {
 	if mock.UpdateUserFunc == nil {
diff --git a/pkg/service/role.go b/pkg/service/role.go
new file mode 100644
index 000000000..b96464fc5
--- /dev/null
+++ b/pkg/service/role.go
@@ -0,0 +1,298 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+const (
+	roleAuditCreated          = "ROLE_CREATED"
+	roleAuditUpdated          = "ROLE_UPDATED"
+	roleAuditDeleted          = "ROLE_DELETED"
+	roleAuditPrivilegeAdded   = "ROLE_PRIVILEGE_ADDED"
+	roleAuditPrivilegeRemoved = "ROLE_PRIVILEGE_REMOVED"
+)
+
+// CreateRole requires actorID to hold roles:manage. Role names are unique.
+func (s *Service) CreateRole(ctx context.Context, name, description, actorID string) (*models.Role, error) {
+	if name == "" {
+		return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
+	}
+	if actorID == "" {
+		return nil, fmt.Errorf("%w: actor_id is required", ErrInvalidInput)
+	}
+	role := &models.Role{
+		ID:          newID(),
+		Name:        name,
+		Description: stringPtrOrNil(description),
+		IsSystem:    false,
+	}
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		if existing, err := s.roles.FindByName(ctx, name); err != nil {
+			return fmt.Errorf("lookup role by name: %w", err)
+		} else if existing != nil {
+			return fmt.Errorf("%w: role %q already exists", ErrAlreadyExists, name)
+		}
+		if err := s.roles.Create(ctx, tx, role); err != nil {
+			return fmt.Errorf("insert role: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, roleAuditCreated, role.ID, map[string]any{
+			"name":     role.Name,
+			"actor_id": actorID,
+		})
+	}); err != nil {
+		return nil, err
+	}
+	return role, nil
+}
+
+// UpdateRole requires actorID to hold roles:manage. System roles cannot be
+// renamed.
+func (s *Service) UpdateRole(ctx context.Context, roleID, name, description, actorID string) (*models.Role, error) {
+	if roleID == "" {
+		return nil, fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if actorID == "" {
+		return nil, fmt.Errorf("%w: actor_id is required", ErrInvalidInput)
+	}
+	var updated *models.Role
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		existing, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if existing == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		if existing.IsSystem && name != "" && name != existing.Name {
+			return fmt.Errorf("%w: cannot rename system role %q", ErrInvalidInput, existing.Name)
+		}
+		if name != "" {
+			existing.Name = name
+		}
+		if description != "" {
+			existing.Description = stringPtrOrNil(description)
+		}
+		if err := s.roles.Update(ctx, tx, existing); err != nil {
+			return fmt.Errorf("update role: %w", err)
+		}
+		updated = existing
+		return s.writeRoleAuditTx(ctx, tx, roleAuditUpdated, existing.ID, map[string]any{
+			"actor_id": actorID,
+			"name":     existing.Name,
+		})
+	}); err != nil {
+		return nil, err
+	}
+	return updated, nil
+}
+
+// DeleteRole requires actorID to hold roles:manage. System roles cannot be
+// deleted. CASCADE drops all assignments + privilege rows for this role.
+func (s *Service) DeleteRole(ctx context.Context, roleID, actorID string) error {
+	if roleID == "" {
+		return fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if actorID == "" {
+		return fmt.Errorf("%w: actor_id is required", ErrInvalidInput)
+	}
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		existing, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if existing == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		if existing.IsSystem {
+			return fmt.Errorf("%w: cannot delete system role %q", ErrInvalidInput, existing.Name)
+		}
+		if err := s.roles.Delete(ctx, tx, roleID); err != nil {
+			return fmt.Errorf("delete role: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, roleAuditDeleted, roleID, map[string]any{
+			"actor_id": actorID,
+			"name":     existing.Name,
+		})
+	})
+}
+
+func (s *Service) GetRole(ctx context.Context, roleID string) (*models.Role, error) {
+	r, err := s.roles.FindByID(ctx, roleID)
+	if err != nil {
+		return nil, fmt.Errorf("get role: %w", err)
+	}
+	if r == nil {
+		return nil, ErrNotFound
+	}
+	return r, nil
+}
+
+func (s *Service) ListRoles(ctx context.Context) ([]models.Role, error) {
+	return s.roles.List(ctx)
+}
+
+func (s *Service) ListRolePrivileges(ctx context.Context, roleID string) ([]models.PrivilegeKey, error) {
+	if roleID == "" {
+		return nil, fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	return s.roles.ListPrivileges(ctx, roleID)
+}
+
+// AddPrivilegeToRole requires actorID to hold roles:manage. The change
+// propagates to every holder of the role.
+func (s *Service) AddPrivilegeToRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+	if roleID == "" {
+		return fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if !models.IsKnownPrivilege(privilege) {
+		return fmt.Errorf("%w: unknown privilege %q", ErrInvalidInput, privilege)
+	}
+	if actorID == "" {
+		return fmt.Errorf("%w: actor_id is required", ErrInvalidInput)
+	}
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		existing, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if existing == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		has, err := s.roles.HasPrivilege(ctx, tx, roleID, privilege)
+		if err != nil {
+			return fmt.Errorf("check role privilege: %w", err)
+		}
+		if has {
+			return fmt.Errorf("%w: role %q already carries %q", ErrAlreadyExists, existing.Name, privilege)
+		}
+		if err := s.roles.AddPrivilege(ctx, tx, roleID, privilege); err != nil {
+			return fmt.Errorf("add role privilege: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, roleAuditPrivilegeAdded, roleID, map[string]any{
+			"actor_id":  actorID,
+			"privilege": privilege,
+			"role_name": existing.Name,
+		})
+	})
+}
+
+// RemovePrivilegeFromRole detaches a privilege from a role. Caller must
+// hold roles:manage. Refuses to remove privileges:grant or roles:manage if
+// no other role carries it.
+func (s *Service) RemovePrivilegeFromRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
+	if roleID == "" {
+		return fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if !models.IsKnownPrivilege(privilege) {
+		return fmt.Errorf("%w: unknown privilege %q", ErrInvalidInput, privilege)
+	}
+	if actorID == "" {
+		return fmt.Errorf("%w: actor_id is required", ErrInvalidInput)
+	}
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		existing, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if existing == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		has, err := s.roles.HasPrivilege(ctx, tx, roleID, privilege)
+		if err != nil {
+			return fmt.Errorf("check role privilege: %w", err)
+		}
+		if !has {
+			return fmt.Errorf("%w: role %q does not carry %q", ErrNotFound, existing.Name, privilege)
+		}
+		if privilege == models.PrivilegeGrant || privilege == models.PrivilegeRolesManage {
+			count, err := s.roles.CountRolesGrantingPrivilege(ctx, tx, privilege)
+			if err != nil {
+				return fmt.Errorf("count roles granting privilege: %w", err)
+			}
+			if count <= 1 {
+				return fmt.Errorf("%w: cannot remove the last source of %q from any role", ErrInvalidInput, privilege)
+			}
+		}
+		if err := s.roles.RemovePrivilege(ctx, tx, roleID, privilege); err != nil {
+			return fmt.Errorf("remove role privilege: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, roleAuditPrivilegeRemoved, roleID, map[string]any{
+			"actor_id":  actorID,
+			"privilege": privilege,
+			"role_name": existing.Name,
+		})
+	})
+}
+
+// assertHasPrivilegeTx fails with ErrInvalidInput when actorID does not
+// hold the privilege directly or via any role.
+func (s *Service) assertHasPrivilegeTx(ctx context.Context, tx *sql.Tx, actorID string, required models.PrivilegeKey) error {
+	direct, err := s.privileges.FindForUpdate(ctx, tx, actorID, required)
+	if err != nil {
+		return fmt.Errorf("lookup actor direct privilege: %w", err)
+	}
+	if direct != nil {
+		return nil
+	}
+	roleKeys, err := s.userRoles.PrivilegesForUser(ctx, actorID)
+	if err != nil {
+		return fmt.Errorf("lookup actor role privileges: %w", err)
+	}
+	for _, k := range roleKeys {
+		if k == required {
+			return nil
+		}
+	}
+	return fmt.Errorf("%w: actor does not hold %s", ErrInvalidInput, required)
+}
+
+func (s *Service) writeRoleAuditTx(ctx context.Context, tx *sql.Tx, eventType string, entityID string, details map[string]any) error {
+	payload, err := json.Marshal(details)
+	if err != nil {
+		return fmt.Errorf("marshal audit details: %w", err)
+	}
+	return s.auditEvents.Create(ctx, tx, &models.AuditEvent{
+		ID:        newID(),
+		EventType: eventType,
+		EventTime: nowUTC(),
+		EntityID:  entityID,
+		Details:   string(payload),
+	})
+}
diff --git a/pkg/service/role_integration_test.go b/pkg/service/role_integration_test.go
new file mode 100644
index 000000000..bf12d91c1
--- /dev/null
+++ b/pkg/service/role_integration_test.go
@@ -0,0 +1,196 @@
+//go:build integration
+
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+func TestCreateRole_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+
+	role, err := svc.CreateRole(ctx(), "operator", "view AMIE + HPC", actor)
+	if err != nil {
+		t.Fatalf("CreateRole: %v", err)
+	}
+	if role.Name != "operator" || role.IsSystem {
+		t.Errorf("role: %+v", role)
+	}
+	if got := countAuditEventsOfType(t, database, "ROLE_CREATED", role.ID); got != 1 {
+		t.Errorf("audit ROLE_CREATED: got %d, want 1", got)
+	}
+}
+
+func TestCreateRole_RejectsWithoutRolesManage(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	// actor has nothing
+	_, err := svc.CreateRole(ctx(), "operator", "", actor)
+	if !errors.Is(err, ErrInvalidInput) {
+		t.Errorf("expected ErrInvalidInput, got %v", err)
+	}
+}
+
+func TestCreateRole_RejectsDuplicateName(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	if _, err := svc.CreateRole(ctx(), "operator", "", actor); err != nil {
+		t.Fatalf("first: %v", err)
+	}
+	_, err := svc.CreateRole(ctx(), "operator", "", actor)
+	if !errors.Is(err, ErrAlreadyExists) {
+		t.Errorf("expected ErrAlreadyExists, got %v", err)
+	}
+}
+
+func TestAddPrivilegeToRole_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	role, err := svc.CreateRole(ctx(), "amie-viewer", "", actor)
+	if err != nil {
+		t.Fatalf("CreateRole: %v", err)
+	}
+
+	if err := svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, actor); err != nil {
+		t.Fatalf("AddPrivilegeToRole: %v", err)
+	}
+	keys, err := svc.ListRolePrivileges(ctx(), role.ID)
+	if err != nil {
+		t.Fatalf("ListRolePrivileges: %v", err)
+	}
+	if len(keys) != 1 || keys[0] != models.PrivilegeAMIERead {
+		t.Errorf("role privileges: got %v, want [amie:read]", keys)
+	}
+	if got := countAuditEventsOfType(t, database, "ROLE_PRIVILEGE_ADDED", role.ID); got != 1 {
+		t.Errorf("audit ROLE_PRIVILEGE_ADDED: got %d, want 1", got)
+	}
+}
+
+func TestAddPrivilegeToRole_RejectsDuplicate(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "amie-viewer", "", actor)
+	if err := svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, actor); err != nil {
+		t.Fatalf("first add: %v", err)
+	}
+	err := svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, actor)
+	if !errors.Is(err, ErrAlreadyExists) {
+		t.Errorf("expected ErrAlreadyExists, got %v", err)
+	}
+}
+
+func TestRemovePrivilegeFromRole_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "amie-viewer", "", actor)
+	_ = svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, actor)
+
+	if err := svc.RemovePrivilegeFromRole(ctx(), role.ID, models.PrivilegeAMIERead, actor); err != nil {
+		t.Fatalf("RemovePrivilegeFromRole: %v", err)
+	}
+	keys, _ := svc.ListRolePrivileges(ctx(), role.ID)
+	if len(keys) != 0 {
+		t.Errorf("role privileges after remove: %v", keys)
+	}
+}
+
+func TestRemovePrivilegeFromRole_RejectsRemovingLastMetaSource(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "controllers", "", actor)
+	_ = svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeGrant, actor)
+	// `actor`'s privileges:grant is via direct seed; the role we just added it
+	// to is the ONLY role carrying it. Removing from the role would leave 0
+	// roles carrying privileges:grant — guard refuses.
+	err := svc.RemovePrivilegeFromRole(ctx(), role.ID, models.PrivilegeGrant, actor)
+	if !errors.Is(err, ErrInvalidInput) {
+		t.Errorf("expected ErrInvalidInput (last source), got %v", err)
+	}
+}
+
+func TestDeleteRole_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "tmp", "", actor)
+	if err := svc.DeleteRole(ctx(), role.ID, actor); err != nil {
+		t.Fatalf("DeleteRole: %v", err)
+	}
+	if _, err := svc.GetRole(ctx(), role.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("expected ErrNotFound, got %v", err)
+	}
+}
+
+func TestDeleteRole_RefusesSystemRole(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	bootstrap := seedUser(t, database, "boot@example.edu")
+	if err := svc.BootstrapSuperAdmin(ctx(), "boot@example.edu", "test"); err != nil {
+		t.Fatalf("bootstrap: %v", err)
+	}
+	roles, _ := svc.ListRoles(ctx())
+	var superAdmin *models.Role
+	for i := range roles {
+		if roles[i].Name == models.SystemRoleSuperAdmin {
+			superAdmin = &roles[i]
+			break
+		}
+	}
+	if superAdmin == nil {
+		t.Fatal("super_admin role not found")
+	}
+	err := svc.DeleteRole(ctx(), superAdmin.ID, bootstrap)
+	if !errors.Is(err, ErrInvalidInput) {
+		t.Errorf("expected ErrInvalidInput (system role), got %v", err)
+	}
+}
+
+// seedPrivilege directly inserts an active grant for any privilege key.
+func seedPrivilege(t *testing.T, database *sqlx.DB, userID string, p models.PrivilegeKey) {
+	t.Helper()
+	if _, err := database.Exec(
+		`INSERT INTO user_privileges (id, user_id, privilege, granted_at, reason)
+		 VALUES (?, ?, ?, NOW(6), 'seed')`,
+		uuid.NewString(), userID, string(p),
+	); err != nil {
+		t.Fatalf("seed privilege %s for %s: %v", p, userID, err)
+	}
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index 910e318d2..bb3b64247 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -54,6 +54,8 @@ type Service struct {
 	userIdentities      store.UserIdentityStore
 	auditEvents         store.AuditEventStore
 	privileges          store.UserPrivilegeStore
+	roles               store.RoleStore
+	userRoles           store.UserRoleStore
 }
 
 // New constructs a Service backed by the supplied database handle.
@@ -80,6 +82,8 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
 		userIdentities:      store.NewUserIdentityStore(database),
 		auditEvents:         store.NewAuditEventStore(database),
 		privileges:          store.NewUserPrivilegeStore(database),
+		roles:               store.NewRoleStore(database),
+		userRoles:           store.NewUserRoleStore(database),
 	}
 }
 
@@ -107,6 +111,8 @@ func NewWithStores(
 	userIdentities store.UserIdentityStore,
 	auditEvents store.AuditEventStore,
 	privileges store.UserPrivilegeStore,
+	roles store.RoleStore,
+	userRoles store.UserRoleStore,
 ) *Service {
 	return &Service{
 		db:                  database,
@@ -129,6 +135,8 @@ func NewWithStores(
 		userIdentities:      userIdentities,
 		auditEvents:         auditEvents,
 		privileges:          privileges,
+		roles:               roles,
+		userRoles:           userRoles,
 	}
 }
 
diff --git a/pkg/service/user_privilege.go b/pkg/service/user_privilege.go
index 25452df82..f4713470b 100644
--- a/pkg/service/user_privilege.go
+++ b/pkg/service/user_privilege.go
@@ -22,26 +22,18 @@ import (
 	"database/sql"
 	"encoding/json"
 	"fmt"
-	"log/slog"
 
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
 const (
-	privilegeAuditGrant     = "PRIVILEGE_GRANTED"
-	privilegeAuditRevoke    = "PRIVILEGE_REVOKED"
-	privilegeAuditBootstrap = "PRIVILEGE_BOOTSTRAPPED"
+	privilegeAuditGrant  = "PRIVILEGE_GRANTED"
+	privilegeAuditRevoke = "PRIVILEGE_REVOKED"
 )
 
 // GrantPrivilege attaches privilege to userID. Caller (granterID) must hold
 // an active privileges:grant.
-func (s *Service) GrantPrivilege(
-	ctx context.Context,
-	userID string,
-	privilege models.PrivilegeKey,
-	granterID string,
-	reason string,
-) (*models.UserPrivilege, error) {
+func (s *Service) GrantPrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, granterID, reason string) (*models.UserPrivilege, error) {
 	if userID == "" {
 		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
 	}
@@ -90,13 +82,7 @@ func (s *Service) GrantPrivilege(
 // full revoke history (who, when, why) is captured in audit_events. The
 // meta-privilege (privileges:grant) cannot be self-revoked and cannot be
 // removed from the last holder.
-func (s *Service) RevokePrivilege(
-	ctx context.Context,
-	userID string,
-	privilege models.PrivilegeKey,
-	revokerID string,
-	reason string,
-) error {
+func (s *Service) RevokePrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID, reason string) error {
 	if userID == "" {
 		return fmt.Errorf("%w: user_id is required", ErrInvalidInput)
 	}
@@ -141,8 +127,8 @@ func (s *Service) RevokePrivilege(
 	})
 }
 
-// HasPrivilege returns true iff an active grant of the named privilege
-// exists for userID.
+// HasPrivilege returns true iff the user holds the named privilege either
+// directly OR through any role assigned to them.
 func (s *Service) HasPrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey) (bool, error) {
 	if userID == "" {
 		return false, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
@@ -150,14 +136,27 @@ func (s *Service) HasPrivilege(ctx context.Context, userID string, privilege mod
 	if !models.IsKnownPrivilege(privilege) {
 		return false, fmt.Errorf("%w: unknown privilege %q", ErrInvalidInput, privilege)
 	}
-	row, err := s.privileges.Find(ctx, userID, privilege)
+	direct, err := s.privileges.Find(ctx, userID, privilege)
 	if err != nil {
-		return false, fmt.Errorf("lookup privilege: %w", err)
+		return false, fmt.Errorf("lookup direct privilege: %w", err)
 	}
-	return row != nil, nil
+	if direct != nil {
+		return true, nil
+	}
+	roleKeys, err := s.userRoles.PrivilegesForUser(ctx, userID)
+	if err != nil {
+		return false, fmt.Errorf("lookup role privileges: %w", err)
+	}
+	for _, k := range roleKeys {
+		if k == privilege {
+			return true, nil
+		}
+	}
+	return false, nil
 }
 
-// ListUserPrivileges returns the user's active privileges.
+// ListUserPrivileges returns only direct grants. Use EffectivePrivileges
+// to include role-derived privileges.
 func (s *Service) ListUserPrivileges(ctx context.Context, userID string) ([]models.UserPrivilege, error) {
 	if userID == "" {
 		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
@@ -169,6 +168,34 @@ func (s *Service) ListUserPrivileges(ctx context.Context, userID string) ([]mode
 	return rows, nil
 }
 
+// EffectivePrivileges returns the union of direct grants and every
+// privilege carried by every role the user holds.
+func (s *Service) EffectivePrivileges(ctx context.Context, userID string) ([]models.PrivilegeKey, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	set := make(map[models.PrivilegeKey]struct{})
+	direct, err := s.privileges.ListByUser(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("list direct privileges: %w", err)
+	}
+	for _, p := range direct {
+		set[p.Privilege] = struct{}{}
+	}
+	roleKeys, err := s.userRoles.PrivilegesForUser(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("list role privileges: %w", err)
+	}
+	for _, k := range roleKeys {
+		set[k] = struct{}{}
+	}
+	out := make([]models.PrivilegeKey, 0, len(set))
+	for k := range set {
+		out = append(out, k)
+	}
+	return out, nil
+}
+
 // ListPrivilegeHolders returns the active holders of privilege.
 func (s *Service) ListPrivilegeHolders(ctx context.Context, privilege models.PrivilegeKey) ([]models.UserPrivilege, error) {
 	if !models.IsKnownPrivilege(privilege) {
@@ -186,76 +213,15 @@ func (s *Service) PrivilegeCatalog() []models.PrivilegeKey {
 	return models.KnownPrivileges()
 }
 
-// BootstrapPrivilegeGrant is called once at server startup if
-// CUSTOS_BOOTSTRAP_ADMIN_EMAIL is set. Looks up the user by email and grants
-// PrivilegeGrant if no active holder exists. Returns nil on every no-op
-// case (no env user, user not found, holder already present); startup must
-// not fail because of this.
-func (s *Service) BootstrapPrivilegeGrant(ctx context.Context, email, source string) error {
-	if email == "" {
-		return nil
-	}
-	user, err := s.users.FindByEmail(ctx, email)
-	if err != nil {
-		return fmt.Errorf("lookup bootstrap user: %w", err)
-	}
-	if user == nil {
-		slog.Warn("bootstrap: user not found, skipping", "email", email)
-		return nil
-	}
-	return s.inTx(ctx, func(tx *sql.Tx) error {
-		existing, err := s.privileges.CountByPrivilege(ctx, tx, models.PrivilegeGrant)
-		if err != nil {
-			return fmt.Errorf("count grant-holders: %w", err)
-		}
-		if existing > 0 {
-			slog.Info("bootstrap: privileges:grant already held by another user, skipping", "email", email)
-			return nil
-		}
-		grant := &models.UserPrivilege{
-			ID:        newID(),
-			UserID:    user.ID,
-			Privilege: models.PrivilegeGrant,
-			GrantedBy: nil,
-			GrantedAt: nowUTC(),
-			Reason:    stringPtrOrNil("bootstrap"),
-		}
-		if err := s.privileges.Create(ctx, tx, grant); err != nil {
-			return fmt.Errorf("insert bootstrap grant: %w", err)
-		}
-		if err := s.writePrivilegeAuditTx(ctx, tx, privilegeAuditBootstrap, user.ID, map[string]any{
-			"privilege": models.PrivilegeGrant,
-			"source":    source,
-		}); err != nil {
-			return fmt.Errorf("audit bootstrap grant: %w", err)
-		}
-		slog.Info("bootstrap: privileges:grant granted", "user_id", user.ID, "email", email, "source", source)
-		return nil
-	})
-}
-
 // assertGranterTx fails with ErrInvalidInput when actorID does not hold an
-// active privileges:grant. The check runs inside the supplied tx with
-// SELECT FOR UPDATE so concurrent grant + revoke serialize.
+// active privileges:grant either directly or via a role. The check runs
+// inside the supplied tx so concurrent grant + revoke serialize.
 func (s *Service) assertGranterTx(ctx context.Context, tx *sql.Tx, actorID string) error {
-	grant, err := s.privileges.FindForUpdate(ctx, tx, actorID, models.PrivilegeGrant)
-	if err != nil {
-		return fmt.Errorf("lookup actor meta privilege: %w", err)
-	}
-	if grant == nil {
-		return fmt.Errorf("%w: actor does not hold %s", ErrInvalidInput, models.PrivilegeGrant)
-	}
-	return nil
+	return s.assertHasPrivilegeTx(ctx, tx, actorID, models.PrivilegeGrant)
 }
 
 // writePrivilegeAuditTx records a privilege lifecycle event in audit_events.
-func (s *Service) writePrivilegeAuditTx(
-	ctx context.Context,
-	tx *sql.Tx,
-	eventType string,
-	entityID string,
-	details map[string]any,
-) error {
+func (s *Service) writePrivilegeAuditTx(ctx context.Context, tx *sql.Tx, eventType string, entityID string, details map[string]any) error {
 	payload, err := json.Marshal(details)
 	if err != nil {
 		return fmt.Errorf("marshal audit details: %w", err)
diff --git a/pkg/service/user_privilege_integration_test.go b/pkg/service/user_privilege_integration_test.go
index ca9cda640..539acdab4 100644
--- a/pkg/service/user_privilege_integration_test.go
+++ b/pkg/service/user_privilege_integration_test.go
@@ -208,43 +208,50 @@ func TestPrivilegeCatalog(t *testing.T) {
 	}
 }
 
-func TestBootstrapPrivilegeGrant_HappyPath(t *testing.T) {
+func TestBootstrapSuperAdmin_HappyPath(t *testing.T) {
 	database := setupTestDB(t)
 	svc := newTestService(database)
 	user := seedUser(t, database, "boot@example.edu")
-	if err := svc.BootstrapPrivilegeGrant(ctx(), "boot@example.edu", "env:TEST"); err != nil {
-		t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+	if err := svc.BootstrapSuperAdmin(ctx(), "boot@example.edu", "env:TEST"); err != nil {
+		t.Fatalf("BootstrapSuperAdmin: %v", err)
 	}
 	if has, err := svc.HasPrivilege(ctx(), user, models.PrivilegeGrant); err != nil || !has {
-		t.Errorf("HasPrivilege after bootstrap: has=%v err=%v", has, err)
+		t.Errorf("HasPrivilege grant after bootstrap: has=%v err=%v", has, err)
 	}
-	if got := countAuditEventsOfType(t, database, "PRIVILEGE_BOOTSTRAPPED", user); got != 1 {
-		t.Errorf("audit PRIVILEGE_BOOTSTRAPPED: got %d, want 1", got)
+	if has, err := svc.HasPrivilege(ctx(), user, models.PrivilegeRolesManage); err != nil || !has {
+		t.Errorf("HasPrivilege roles:manage after bootstrap: has=%v err=%v", has, err)
+	}
+	if got := countAuditEventsOfType(t, database, "ROLE_BOOTSTRAPPED", user); got != 1 {
+		t.Errorf("audit ROLE_BOOTSTRAPPED: got %d, want 1", got)
 	}
 }
 
-func TestBootstrapPrivilegeGrant_NoOpWhenHolderExists(t *testing.T) {
+func TestBootstrapSuperAdmin_NoOpWhenRoleHasHolder(t *testing.T) {
 	database := setupTestDB(t)
 	svc := newTestService(database)
-	existing := seedUser(t, database, "existing@example.edu")
-	seedPrivilegeGrant(t, database, existing)
-	if err := svc.BootstrapPrivilegeGrant(ctx(), "another@example.edu", "env:TEST"); err != nil {
-		t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+	first := seedUser(t, database, "first@example.edu")
+	another := seedUser(t, database, "another@example.edu")
+	_ = another
+	if err := svc.BootstrapSuperAdmin(ctx(), "first@example.edu", "env:TEST"); err != nil {
+		t.Fatalf("first bootstrap: %v", err)
 	}
-	rows, err := svc.ListPrivilegeHolders(ctx(), models.PrivilegeGrant)
-	if err != nil {
-		t.Fatalf("ListPrivilegeHolders: %v", err)
+	if err := svc.BootstrapSuperAdmin(ctx(), "another@example.edu", "env:TEST"); err != nil {
+		t.Fatalf("second bootstrap: %v", err)
+	}
+	// only `first` should hold super_admin
+	if has, err := svc.HasPrivilege(ctx(), first, models.PrivilegeGrant); err != nil || !has {
+		t.Errorf("first should still hold grant: has=%v err=%v", has, err)
 	}
-	if len(rows) != 1 || rows[0].UserID != existing {
-		t.Errorf("holders after bootstrap no-op: got %v, want only [%s]", rows, existing)
+	if has, err := svc.HasPrivilege(ctx(), another, models.PrivilegeGrant); err != nil || has {
+		t.Errorf("another should NOT hold grant: has=%v err=%v", has, err)
 	}
 }
 
-func TestBootstrapPrivilegeGrant_NoOpWhenEmailNotFound(t *testing.T) {
+func TestBootstrapSuperAdmin_NoOpWhenEmailNotFound(t *testing.T) {
 	database := setupTestDB(t)
 	svc := newTestService(database)
-	if err := svc.BootstrapPrivilegeGrant(ctx(), "missing@example.edu", "env:TEST"); err != nil {
-		t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+	if err := svc.BootstrapSuperAdmin(ctx(), "missing@example.edu", "env:TEST"); err != nil {
+		t.Fatalf("BootstrapSuperAdmin: %v", err)
 	}
 	rows, err := svc.ListPrivilegeHolders(ctx(), models.PrivilegeGrant)
 	if err != nil {
diff --git a/pkg/service/user_role.go b/pkg/service/user_role.go
new file mode 100644
index 000000000..0f11fb35a
--- /dev/null
+++ b/pkg/service/user_role.go
@@ -0,0 +1,302 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"log/slog"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+const (
+	userRoleAuditGranted   = "ROLE_GRANTED"
+	userRoleAuditRevoked   = "ROLE_REVOKED"
+	userRoleAuditBootstrap = "ROLE_BOOTSTRAPPED"
+)
+
+// GrantRoleToUser requires granterID to hold roles:manage.
+func (s *Service) GrantRoleToUser(ctx context.Context, userID, roleID, granterID, reason string) (*models.UserRole, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	if roleID == "" {
+		return nil, fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if granterID == "" {
+		return nil, fmt.Errorf("%w: granter_id is required", ErrInvalidInput)
+	}
+	assignment := &models.UserRole{
+		UserID:    userID,
+		RoleID:    roleID,
+		GrantedBy: stringPtrOrNil(granterID),
+		GrantedAt: nowUTC(),
+		Reason:    stringPtrOrNil(reason),
+	}
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, granterID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		role, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if role == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		existing, err := s.userRoles.FindForUpdate(ctx, tx, userID, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup existing assignment: %w", err)
+		}
+		if existing != nil {
+			return fmt.Errorf("%w: user already holds role %q", ErrAlreadyExists, role.Name)
+		}
+		if err := s.userRoles.Create(ctx, tx, assignment); err != nil {
+			return fmt.Errorf("insert role assignment: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, userRoleAuditGranted, userID, map[string]any{
+			"actor_id":  granterID,
+			"role_id":   roleID,
+			"role_name": role.Name,
+			"reason":    reason,
+		})
+	}); err != nil {
+		return nil, err
+	}
+	return assignment, nil
+}
+
+// RevokeRoleFromUser requires revokerID to hold roles:manage. Refuses if
+// the revoke would leave no holder of privileges:grant or roles:manage anywhere.
+func (s *Service) RevokeRoleFromUser(ctx context.Context, userID, roleID, revokerID, reason string) error {
+	if userID == "" {
+		return fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	if roleID == "" {
+		return fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	if revokerID == "" {
+		return fmt.Errorf("%w: revoker_id is required", ErrInvalidInput)
+	}
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		if err := s.assertHasPrivilegeTx(ctx, tx, revokerID, models.PrivilegeRolesManage); err != nil {
+			return err
+		}
+		role, err := s.roles.FindByID(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup role: %w", err)
+		}
+		if role == nil {
+			return fmt.Errorf("%w: role %q does not exist", ErrNotFound, roleID)
+		}
+		existing, err := s.userRoles.FindForUpdate(ctx, tx, userID, roleID)
+		if err != nil {
+			return fmt.Errorf("lookup assignment: %w", err)
+		}
+		if existing == nil {
+			return fmt.Errorf("%w: user does not hold role %q", ErrNotFound, role.Name)
+		}
+		if err := s.assertNotLastMetaHolderTx(ctx, tx, userID, roleID); err != nil {
+			return err
+		}
+		if err := s.userRoles.Delete(ctx, tx, userID, roleID); err != nil {
+			return fmt.Errorf("delete role assignment: %w", err)
+		}
+		return s.writeRoleAuditTx(ctx, tx, userRoleAuditRevoked, userID, map[string]any{
+			"actor_id":  revokerID,
+			"role_id":   roleID,
+			"role_name": role.Name,
+			"reason":    reason,
+		})
+	})
+}
+
+func (s *Service) ListUserRoles(ctx context.Context, userID string) ([]models.UserRole, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	return s.userRoles.ListByUser(ctx, userID)
+}
+
+func (s *Service) ListRoleHolders(ctx context.Context, roleID string) ([]models.UserRole, error) {
+	if roleID == "" {
+		return nil, fmt.Errorf("%w: role_id is required", ErrInvalidInput)
+	}
+	return s.userRoles.ListByRole(ctx, roleID)
+}
+
+// BootstrapSuperAdmin ensures the super_admin role exists and grants it to
+// the user with the named email. Idempotent — returns nil on every no-op
+// case so a failed bootstrap never blocks server start.
+func (s *Service) BootstrapSuperAdmin(ctx context.Context, email, source string) error {
+	if email == "" {
+		return nil
+	}
+	user, err := s.users.FindByEmail(ctx, email)
+	if err != nil {
+		return fmt.Errorf("lookup bootstrap user: %w", err)
+	}
+	if user == nil {
+		slog.Warn("bootstrap: user not found, skipping", "email", email)
+		return nil
+	}
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		role, err := s.ensureSuperAdminRoleTx(ctx, tx)
+		if err != nil {
+			return fmt.Errorf("ensure super_admin role: %w", err)
+		}
+		existing, err := s.userRoles.FindForUpdate(ctx, tx, user.ID, role.ID)
+		if err != nil {
+			return fmt.Errorf("lookup existing assignment: %w", err)
+		}
+		if existing != nil {
+			slog.Info("bootstrap: super_admin already granted to user, skipping", "email", email)
+			return nil
+		}
+		// If another user already holds super_admin, install is past bootstrap.
+		holders, err := s.userRoles.ListByRole(ctx, role.ID)
+		if err != nil {
+			return fmt.Errorf("count super_admin holders: %w", err)
+		}
+		if len(holders) > 0 {
+			slog.Info("bootstrap: super_admin already held by another user, skipping", "email", email)
+			return nil
+		}
+		assignment := &models.UserRole{
+			UserID:    user.ID,
+			RoleID:    role.ID,
+			GrantedBy: nil,
+			GrantedAt: nowUTC(),
+			Reason:    stringPtrOrNil("bootstrap"),
+		}
+		if err := s.userRoles.Create(ctx, tx, assignment); err != nil {
+			return fmt.Errorf("insert bootstrap assignment: %w", err)
+		}
+		if err := s.writeRoleAuditTx(ctx, tx, userRoleAuditBootstrap, user.ID, map[string]any{
+			"role_id":   role.ID,
+			"role_name": role.Name,
+			"source":    source,
+		}); err != nil {
+			return fmt.Errorf("audit bootstrap assignment: %w", err)
+		}
+		slog.Info("bootstrap: super_admin granted", "user_id", user.ID, "email", email, "source", source)
+		return nil
+	})
+}
+
+// ensureSuperAdminRoleTx creates the super_admin role with the meta
+// privileges if it doesn't exist, otherwise returns the existing row.
+func (s *Service) ensureSuperAdminRoleTx(ctx context.Context, tx *sql.Tx) (*models.Role, error) {
+	role, err := s.roles.FindByName(ctx, models.SystemRoleSuperAdmin)
+	if err != nil {
+		return nil, fmt.Errorf("lookup super_admin role: %w", err)
+	}
+	if role == nil {
+		role = &models.Role{
+			ID:          newID(),
+			Name:        models.SystemRoleSuperAdmin,
+			Description: stringPtrOrNil("bootstrap role carrying privileges:grant and roles:manage"),
+			IsSystem:    true,
+		}
+		if err := s.roles.Create(ctx, tx, role); err != nil {
+			return nil, fmt.Errorf("create super_admin role: %w", err)
+		}
+	}
+	for _, key := range []models.PrivilegeKey{models.PrivilegeGrant, models.PrivilegeRolesManage} {
+		has, err := s.roles.HasPrivilege(ctx, tx, role.ID, key)
+		if err != nil {
+			return nil, fmt.Errorf("check super_admin privilege %s: %w", key, err)
+		}
+		if !has {
+			if err := s.roles.AddPrivilege(ctx, tx, role.ID, key); err != nil {
+				return nil, fmt.Errorf("attach %s to super_admin: %w", key, err)
+			}
+		}
+	}
+	return role, nil
+}
+
+// assertNotLastMetaHolderTx refuses revoke if it would leave no holder of
+// privileges:grant or roles:manage anywhere in the system.
+func (s *Service) assertNotLastMetaHolderTx(ctx context.Context, tx *sql.Tx, userID, roleID string) error {
+	for _, key := range []models.PrivilegeKey{models.PrivilegeGrant, models.PrivilegeRolesManage} {
+		rolePrivs, err := s.roles.ListPrivileges(ctx, roleID)
+		if err != nil {
+			return fmt.Errorf("list role privileges: %w", err)
+		}
+		grants := false
+		for _, k := range rolePrivs {
+			if k == key {
+				grants = true
+				break
+			}
+		}
+		if !grants {
+			continue
+		}
+		count, err := s.countUsersHoldingPrivilegeTx(ctx, tx, key)
+		if err != nil {
+			return fmt.Errorf("count holders of %s: %w", key, err)
+		}
+		// Would the revoke drop count to 0? Only if this user is the lone
+		// holder AND has no other source for this key.
+		if count <= 1 {
+			other, err := s.userHasPrivilegeOutsideTx(ctx, tx, userID, key, roleID)
+			if err != nil {
+				return fmt.Errorf("check alternative source: %w", err)
+			}
+			if !other {
+				return fmt.Errorf("%w: cannot revoke; would leave no holder of %s", ErrInvalidInput, key)
+			}
+		}
+	}
+	return nil
+}
+
+func (s *Service) countUsersHoldingPrivilegeTx(ctx context.Context, tx *sql.Tx, key models.PrivilegeKey) (int, error) {
+	var n int
+	err := tx.QueryRowContext(ctx,
+		`SELECT COUNT(DISTINCT user_id) FROM (
+		   SELECT user_id FROM user_privileges WHERE privilege = ?
+		   UNION
+		   SELECT ur.user_id FROM user_roles ur
+		     JOIN role_privileges rp ON rp.role_id = ur.role_id
+		     WHERE rp.privilege = ?
+		 ) AS holders`, key, key).Scan(&n)
+	return n, err
+}
+
+// userHasPrivilegeOutsideTx checks if userID gets the privilege from any
+// source other than excludeRoleID.
+func (s *Service) userHasPrivilegeOutsideTx(ctx context.Context, tx *sql.Tx, userID string, key models.PrivilegeKey, excludeRoleID string) (bool, error) {
+	var n int
+	err := tx.QueryRowContext(ctx,
+		`SELECT COUNT(*) FROM (
+		   SELECT 1 FROM user_privileges WHERE user_id = ? AND privilege = ?
+		   UNION ALL
+		   SELECT 1 FROM user_roles ur
+		     JOIN role_privileges rp ON rp.role_id = ur.role_id
+		     WHERE ur.user_id = ? AND ur.role_id <> ? AND rp.privilege = ?
+		 ) AS sources`, userID, key, userID, excludeRoleID, key).Scan(&n)
+	if err != nil {
+		return false, err
+	}
+	return n > 0, nil
+}
diff --git a/pkg/service/user_role_integration_test.go b/pkg/service/user_role_integration_test.go
new file mode 100644
index 000000000..628fb6424
--- /dev/null
+++ b/pkg/service/user_role_integration_test.go
@@ -0,0 +1,186 @@
+//go:build integration
+
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+func TestGrantRoleToUser_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	granter := seedUser(t, database, "granter@example.edu")
+	target := seedUser(t, database, "target@example.edu")
+	seedPrivilege(t, database, granter, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "amie-viewer", "", granter)
+	_ = svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, granter)
+
+	if _, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, "ops"); err != nil {
+		t.Fatalf("GrantRoleToUser: %v", err)
+	}
+	// target should now have amie:read via the role
+	if has, err := svc.HasPrivilege(ctx(), target, models.PrivilegeAMIERead); err != nil || !has {
+		t.Errorf("HasPrivilege amie:read via role: has=%v err=%v", has, err)
+	}
+}
+
+func TestGrantRoleToUser_RejectsDuplicate(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	granter := seedUser(t, database, "granter@example.edu")
+	target := seedUser(t, database, "target@example.edu")
+	seedPrivilege(t, database, granter, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "viewer", "", granter)
+
+	if _, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, ""); err != nil {
+		t.Fatalf("first: %v", err)
+	}
+	_, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, "")
+	if !errors.Is(err, ErrAlreadyExists) {
+		t.Errorf("expected ErrAlreadyExists, got %v", err)
+	}
+}
+
+func TestRevokeRoleFromUser_HappyPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	granter := seedUser(t, database, "granter@example.edu")
+	target := seedUser(t, database, "target@example.edu")
+	seedPrivilege(t, database, granter, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "viewer", "", granter)
+	_ = svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, granter)
+	if _, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, ""); err != nil {
+		t.Fatalf("grant: %v", err)
+	}
+	if err := svc.RevokeRoleFromUser(ctx(), target, role.ID, granter, "rotated"); err != nil {
+		t.Fatalf("RevokeRoleFromUser: %v", err)
+	}
+	if has, err := svc.HasPrivilege(ctx(), target, models.PrivilegeAMIERead); err != nil || has {
+		t.Errorf("HasPrivilege after revoke: has=%v err=%v", has, err)
+	}
+}
+
+func TestRevokeRoleFromUser_RejectsLastMetaHolder(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	boot := seedUser(t, database, "boot@example.edu")
+	if err := svc.BootstrapSuperAdmin(ctx(), "boot@example.edu", "test"); err != nil {
+		t.Fatalf("bootstrap: %v", err)
+	}
+	roles, _ := svc.ListRoles(ctx())
+	var superAdmin *models.Role
+	for i := range roles {
+		if roles[i].Name == models.SystemRoleSuperAdmin {
+			superAdmin = &roles[i]
+		}
+	}
+	// boot is the only holder of super_admin. To call revoke we need an actor
+	// with roles:manage. Use boot themselves — but revoking boot's own role
+	// would leave 0 holders of meta-privileges, so the guard should refuse.
+	err := svc.RevokeRoleFromUser(ctx(), boot, superAdmin.ID, boot, "self")
+	if !errors.Is(err, ErrInvalidInput) {
+		t.Errorf("expected ErrInvalidInput (last meta holder), got %v", err)
+	}
+}
+
+func TestHasPrivilege_UnionsDirectAndRole(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	granter := seedUser(t, database, "granter@example.edu")
+	target := seedUser(t, database, "target@example.edu")
+	seedPrivilege(t, database, granter, models.PrivilegeRolesManage)
+	seedPrivilege(t, database, granter, models.PrivilegeGrant)
+
+	// Grant direct amie:read
+	if _, err := svc.GrantPrivilege(ctx(), target, models.PrivilegeAMIERead, granter, ""); err != nil {
+		t.Fatalf("direct grant: %v", err)
+	}
+	// Grant role with hpc:read
+	role, _ := svc.CreateRole(ctx(), "hpc-viewer", "", granter)
+	_ = svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeHPCRead, granter)
+	if _, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, ""); err != nil {
+		t.Fatalf("role grant: %v", err)
+	}
+	// Target should have BOTH amie:read (direct) and hpc:read (via role)
+	for _, key := range []models.PrivilegeKey{models.PrivilegeAMIERead, models.PrivilegeHPCRead} {
+		has, err := svc.HasPrivilege(ctx(), target, key)
+		if err != nil || !has {
+			t.Errorf("HasPrivilege %s: has=%v err=%v", key, has, err)
+		}
+	}
+	// Effective should include both
+	keys, err := svc.EffectivePrivileges(ctx(), target)
+	if err != nil {
+		t.Fatalf("EffectivePrivileges: %v", err)
+	}
+	seen := map[models.PrivilegeKey]bool{}
+	for _, k := range keys {
+		seen[k] = true
+	}
+	if !seen[models.PrivilegeAMIERead] || !seen[models.PrivilegeHPCRead] {
+		t.Errorf("effective set missing keys: %v", keys)
+	}
+}
+
+func TestUpdateRolePrivileges_PropagatesToHolders(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	granter := seedUser(t, database, "granter@example.edu")
+	target := seedUser(t, database, "target@example.edu")
+	seedPrivilege(t, database, granter, models.PrivilegeRolesManage)
+	role, _ := svc.CreateRole(ctx(), "evolving", "", granter)
+	if _, err := svc.GrantRoleToUser(ctx(), target, role.ID, granter, ""); err != nil {
+		t.Fatalf("grant role: %v", err)
+	}
+	// Target holds the role but no privilege yet.
+	if has, err := svc.HasPrivilege(ctx(), target, models.PrivilegeAMIERead); err != nil || has {
+		t.Errorf("pre-add HasPrivilege: has=%v err=%v", has, err)
+	}
+	// Add a privilege to the role — target should gain it transparently.
+	if err := svc.AddPrivilegeToRole(ctx(), role.ID, models.PrivilegeAMIERead, granter); err != nil {
+		t.Fatalf("add: %v", err)
+	}
+	if has, err := svc.HasPrivilege(ctx(), target, models.PrivilegeAMIERead); err != nil || !has {
+		t.Errorf("post-add HasPrivilege: has=%v err=%v", has, err)
+	}
+	// Remove it — target loses it transparently.
+	if err := svc.RemovePrivilegeFromRole(ctx(), role.ID, models.PrivilegeAMIERead, granter); err != nil {
+		t.Fatalf("remove: %v", err)
+	}
+	if has, err := svc.HasPrivilege(ctx(), target, models.PrivilegeAMIERead); err != nil || has {
+		t.Errorf("post-remove HasPrivilege: has=%v err=%v", has, err)
+	}
+}
+
+func TestRolesManageRequiredForRoleOps(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	// Actor has privileges:grant but NOT roles:manage — role ops must refuse.
+	actor := seedUser(t, database, "actor@example.edu")
+	seedPrivilege(t, database, actor, models.PrivilegeGrant)
+
+	_, err := svc.CreateRole(ctx(), "x", "", actor)
+	if !errors.Is(err, ErrInvalidInput) {
+		t.Errorf("CreateRole without roles:manage: got %v, want ErrInvalidInput", err)
+	}
+}