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)
+ }
+}