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 <[email protected]>
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, "[email protected]")
+       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, "[email protected]")
+       // 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, "[email protected]")
+       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, "[email protected]")
+       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, "[email protected]")
+       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, "[email protected]")
+       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, "[email protected]")
+       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, "[email protected]")
+       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, "[email protected]")
+       if err := svc.BootstrapSuperAdmin(ctx(), "[email protected]", "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, "[email protected]")
-       if err := svc.BootstrapPrivilegeGrant(ctx(), "[email protected]", 
"env:TEST"); err != nil {
-               t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+       if err := svc.BootstrapSuperAdmin(ctx(), "[email protected]", 
"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, "[email protected]")
-       seedPrivilegeGrant(t, database, existing)
-       if err := svc.BootstrapPrivilegeGrant(ctx(), "[email protected]", 
"env:TEST"); err != nil {
-               t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+       first := seedUser(t, database, "[email protected]")
+       another := seedUser(t, database, "[email protected]")
+       _ = another
+       if err := svc.BootstrapSuperAdmin(ctx(), "[email protected]", 
"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(), "[email protected]", 
"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(), "[email protected]", 
"env:TEST"); err != nil {
-               t.Fatalf("BootstrapPrivilegeGrant: %v", err)
+       if err := svc.BootstrapSuperAdmin(ctx(), "[email protected]", 
"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, "[email protected]")
+       target := seedUser(t, database, "[email protected]")
+       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, "[email protected]")
+       target := seedUser(t, database, "[email protected]")
+       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, "[email protected]")
+       target := seedUser(t, database, "[email protected]")
+       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, "[email protected]")
+       if err := svc.BootstrapSuperAdmin(ctx(), "[email protected]", "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, "[email protected]")
+       target := seedUser(t, database, "[email protected]")
+       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, "[email protected]")
+       target := seedUser(t, database, "[email protected]")
+       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, "[email protected]")
+       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)
+       }
+}

Reply via email to