Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 353 additions & 0 deletions proposals/new/Custom-Project-Roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
# Proposal: Custom Project Roles
Author: Max Graustenzel (@maxgraustenzel-create)
Discussion: #18124

## Abstract

Add support for custom project roles in Harbor, allowing system administrators to create roles with flexible permission combinations beyond the five built-in roles. This extends the existing RBAC system to enable runtime role creation without code changes, addressing long-standing community requests for role customization while maintaining full backward compatibility.

## Background

Harbor currently provides five hardcoded project roles (Project Admin, Maintainer, Developer, Guest, Limited Guest) with fixed permissions defined in `rbac_role.go`. Organizations have diverse security and workflow requirements that cannot be met by these predefined roles alone.

**Current Limitations:**
- Role permissions are hardcoded in Go source code
- Adding new roles requires code changes and Harbor releases
- Organizations cannot adapt roles to specific workflows (e.g., "DevOps Engineer" with artifact management + robot creation but no member management)
- No middle-ground between overly permissive and overly restrictive built-in roles

**Community Demand:**
- Issue #18124 and related issues (#18143, #21306, #12062, #8632, #1486) have 60+ positive reactions
- Consistent feedback requesting role customization capabilities
- Organizations work around limitations by creating multiple projects or granting excessive permissions

**Example Use Cases:**
- **DevOps Engineer:** Push/pull artifacts + manage robot accounts, but cannot manage project members
- **Security Auditor:** Read-only access + trigger vulnerability scans, but cannot modify artifacts
- **Release Manager:** Manage artifacts + set tag immutability, but cannot delete repositories

## Proposal

Extend Harbor's existing RBAC infrastructure to support custom roles by linking the existing `role` table to the `permission_policy` table through the `role_permission` table.

**Core Changes:**

1. **Database:** Move role permissions from hardcoded `rbac_role.go` to database (`role_permission` table)
2. **API:** Add role CRUD endpoints for system administrators
3. **UI:** Add role management interface in System Administration section
Comment thread
maxgraustenzel-create marked this conversation as resolved.
4. **Security:** Implement privilege escalation prevention and audit logging
5. **Caching:** Load permissions at login (session-scoped cache)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the back and forth.

The proposal suggests session-scoped caching at login, with updates applying on the next login. While this is simple, it poses a severe security risk in enterprise, high-availability, or multi-replica Harbor setups:

  • If a custom role's permission is revoked (e.g., a "Security Auditor" role has access to artifacts revoked), the user can continue executing actions because their session-scoped memory cache on their specific pod is stale.
  • Logouts or token expirations are not immediate enough for compliance-critical revocations.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding the same caching mechanism is used for the role assignement itself. The use case that somebody gets a critical role revoked while logged in is probably more likely and is managed in the same way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I get where you're coming from about role assignments having some cache lag today, but there’s a big difference here.

Right now, standard roles are hardcoded as compile-time constants in rbac_role.go. Since they’re locked in Go memory, they can never change while the server is running. But by moving these to the database for custom roles, we're introducing a fully dynamic state. We can’t really apply the same static caching assumptions to dynamic DB records, especially when a single role change can instantly impact thousands of users across multiple projects.

Since we want to ship this safely in v2.16.0, we should make sure changes to custom roles propagate across all Harbor instances without a massive lag.Instead of building a super complex sync or tracking lock system, maybe we can go with a lightweight compromise:

  • Whenever an admin edits or deletes a custom role, we write a simple version tag or timestamp in Redis (like custom_roles:last_update).
  • When evaluating permissions during a session, the evaluator does a quick check against that Redis timestamp. If the local cache is out of date, it lazily reloads the role definitions.

How does that sound to you?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not use Redis here. This add another complexity dimension. A KISS approach would be if a custom role changes, we can invalidate all sessions, forcing users to log in again. Custom role change is something that will happen once or twice during the entire lifecycle of a custom_role. Once a role is defined, it is unlikely to ever change again. People (admins) who are capable of changing roles will not do it since they are unable to predict consequences for the users since they are disconnected from the user base.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe not invalidate all sessions, but the sessions that associate with the updated role.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @Vad1mo — based on the discussion in the last community meeting, @maxgraustenzel-create is leaning toward my Redis-based suggestion.

Since you had a different opinion, I asked @maxgraustenzel-create to sync with you as well. Let’s discuss together and decide which approach would be best to move forward with.

@Vad1mo Vad1mo Jun 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why I do not want this on Redis:

Putting authorization behind a per-request Redis round-trip (a version-key check is still a round-trip, or a local cache of the version key, which just moves the staleness question up one level) puts the most security-critical path behind an availability and latency dependency. We just got that exact failure mode:
goharbor/harbor#23335. The config cache's FetchOrSave Redis path, hit per request, piled up on a keyMutex, exhausted the DB connection pool, and hung core permanently. Authz per request is precisely that hot path. There have also been repeated attempts to move config caching off Redis to memory for the same traffic
reason (goharbor/harbor#19156, goharbor/harbor#19163, goharbor/harbor#19164). Adding a brand new Redis dependency for RBAC walks straight back into the problem those PRs were trying to escape.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxgraustenzel-create Hi, could you please clarify the Harbor scenario you mentioned earlier? I just tested a specific use case with two users: admin and test. First, admin granted test permissions for Project A and Project B. Once test logged in, both projects were visible. Then, admin revoked these permissions and removed test from both projects. As a result, test immediately lost access to Projects A and B without needing to re-log in.

@chlins is right that role assignment is immediate.

What's frozen at login (stale until session refresh)

The thing chlins test never varied: user.GroupIDs. The session middleware rebuilds the user entirely from the session blob, with no DB re-read:

// session.go:Generate
userInterface := store.Get(req.Context(), "user")
user, ok := userInterface.(models.User) // GroupIDs comes straight from here
return local.NewSecurityContext(&user)

GroupIDs is set once at login (oidc/helper.go:494 for OIDC, ldap/ldap.go:194 for LDAP). So for a session-based login (UI, and OIDC web), if you remove a user from an LDAP/OIDC group, ListRoles's entity_id in (GroupIDs) clause still uses the old groups until the session is rewritten. Same applies to SysAdminFlag/AdminRoleInAuth, which IsSysAdmin() reads off the frozen
session user.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxgraustenzel-create Any updates or conclusion for this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the discussion below presents the several approaches regarding the caching. The proposed choice caches permissions on the node level for 1 second which allows to reduce the performance impact.

The proposed caching solution is configurable through environment variables, allowing to balance between performance and session coherence. The use case where 1 second of cache propagation between nodes is considered to long is covered through the cache environment variables. Those variables further allow to balance the work between redis and the DB.


**Key Design Decisions:**

- **Reuse existing infrastructure:** `role`, `permission_policy`, `role_permission`, and `project_member` tables already exist
- **Minimal schema changes:** Only extend `role` table with metadata columns (`is_builtin`, `description`, `modified`, `created_by`, `modified_by`, timestamps)
- **Discriminator pattern:** `role_permission.role_type` distinguishes 'project-role' (users/groups) from 'robotaccount' (direct permissions)
- **System admin only:** Only system administrators can create/modify custom roles (project admins assign roles, existing workflow unchanged)
- **Built-in role protection:** Built-in roles can be modified but not deleted; modifications are tracked and reversible
Comment thread
maxgraustenzel-create marked this conversation as resolved.
Comment thread
chlins marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Built-in roles must remain completely immutable to serve as a secure baseline.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for me

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxgraustenzel-create Please update this part to align the conclusion(built-in role is immutable).


**Technical Architecture:**

```
┌──────────────────────────────────────────────────────────────┐
│ Before: Hardcoded in rbac_role.go │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ users/groups │ │
│ └────────┬────────┘ │
│ ↑ │
│ │ │
│ ┌─────────────────┐ │
│ │ project_member │ │
│ └────────┬────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ role (table) │ │
│ └────────┬────────┘ │
│ │ │
│ ↓ │
│ permissions │
│ (hardcoded in │
│ rbac_role.go) │
│ │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ After: Database-driven │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ users/groups │ │
│ └────────┬────────┘ │
│ │ │
│ ↑ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ project_member │───────→│ role (table) │ │
│ └─────────────────┘ └────────┬────────┘ │
│ ↑ │
│ │ │
│ ┌─────────────────┐ │
│ │ role_permission │ │
│ │ (role_type= │ │
│ │ 'project-role') │ │
│ └────────┬────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ permission_ │ │
│ │ policy (table) │ │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Comment thread
maxgraustenzel-create marked this conversation as resolved.
```

**User Workflow:**

1. **System Admin:** Creates custom role "DevOps Engineer" with specific permissions
2. **Project Admin:** Assigns "DevOps Engineer" role to user/group (same as built-in roles)
3. **User Login:** Permissions loaded into session cache
4. **Authorization:** Permission checks use cached permissions (O(1) lookup)

## Non-Goals

**Explicitly out of scope for this proposal:**

1. **Repository-level permissions:** Permissions apply at project level (all repositories in project). Repository-level permission assignment is tracked separately in Issue #10159
2. **Global role assignments:** Custom roles are assigned per-project (user can have different roles in different projects). Global role assignment is tracked in Issue #8351
3. **Role templates/marketplace:** No predefined custom role templates in initial release
4. **Role inheritance/composition:** Roles are flat, not hierarchical
5. **Real-time permission updates:** Permission changes apply at next login (not mid-session)
6. **Robot account roles:** Robots continue using direct permission assignment (no role concept)

## Rationale

**Why extend existing RBAC vs. creating a new system:**

✅ **Advantages:**
- Leverages existing, battle-tested permission infrastructure
- Minimal code changes (extend, don't replace)
- No breaking changes to APIs or database schema
- Consistent with Harbor's architectural patterns
- Easy to understand for existing Harbor users and contributors

❌ **Alternative: New parallel permission system:**
- Would require maintaining two RBAC systems
- Breaking changes to existing code
- Higher risk of bugs and security issues
- More complex for users to understand

**Why database-driven vs. more built-in roles:**

✅ **Advantages:**
- Organizations have diverse, unpredictable needs
- Reduces Harbor maintenance burden (no code changes for role requests)
- Enables rapid adaptation to new workflows
- Doesn't clutter built-in role list

❌ **Alternative: Add more built-in roles:**
- Cannot cover all use cases
- Each new role requires code review, testing, release cycle
- Built-in role list becomes overwhelming
- Organizations still request customization

**Why system admin only for role creation:**

✅ **Advantages:**
- Centralized governance and security control
- Prevents permission sprawl
- Aligns with enterprise security models
- Simpler initial implementation

❌ **Alternative: Project admins create custom roles:**
- Higher risk of permission sprawl
- Inconsistent roles across projects
- More complex audit trail
- Can be added later if needed

**Why session-scoped caching:**

✅ **Advantages:**
- Fast permission checks (O(1) in-memory lookup)
- No database queries during requests
- Simple cache invalidation (logout = clear)
- Minimal performance impact

❌ **Alternative: Real-time permission updates:**
- Requires complex cache invalidation across sessions
- Performance overhead for permission change propagation
- Added complexity for minimal benefit (permissions rarely change mid-session)

## Compatibility

**Backward Compatibility: Fully maintained**

✅ **No breaking changes:**
- All existing APIs maintain contracts
- Built-in roles function identically
- Existing role assignments preserved
- Authentication/authorization flow unchanged
- Database migration is reversible

✅ **Migration strategy:**
- Zero-downtime migration
- Built-in role permissions migrated from `rbac_role.go` to `role_permission` table

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrating rbac_role.go's static policies into role_permission is cleaner, but we must protect against:

  • High DB query volume during login/CLI handshakes.
  • Missing indexes on role_permission combined with role_type and associated keys.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrating built-in role permissions from rbac_role.go into role_permission creates two sources of truth for the same roles. Please outline the workflow we should follow in the future. For example, if we need to add a new built-in role, should we add it to both the code and the database?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My proposal is that there will be no more hardcoded role permissions in the rbac_role.go. The roles and permissions are defined exclusively inside the database. The corresponding datasets are inserted to the tables as part of the version update workflow.

The role table is extended with some attributes, and one of them is used to identify build-in roles that can't be modified (checked on frontend and backend). If future versions introduce new build-in roles, the corresponding data needs to be added to the DB as part of version update.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please incorporate into the design how we will insert new built-in types into the database in future versions, whether through migration SQL or through code.

- Existing roles automatically marked `is_builtin=true`
- All user/group assignments preserved
- Rollback: Standard Harbor rollback procedure (custom roles become inaccessible but data preserved)

**API Compatibility:**

- **New endpoints:** `/api/v2.0/roles/*` (no conflicts with existing endpoints)
- **Extended endpoint:** `/api/v2.0/permissions` (already existed for robots, now includes role permissions)
- **Unchanged endpoints:** All existing role assignment APIs work with custom roles (transparent)

**UI Compatibility:**

- New "Roles" section in System Administration (no impact on existing UI)
- Project member assignment dropdown includes custom roles (alongside built-in roles)
- No changes to existing workflows

**Performance Impact:**

- **Login:** +50-100ms (one-time permission load)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to run a proper stress test with a sustained load of 500 requests, not just a single benchmark. We need accurate cost metrics, and our commercial deployments handle massive loads. Also, can you grab the CPU/Memory trends for the DB pod? I'm worried the current resource limits won't be enough after we bump Harbor to v2.16.

This data is essential for my evaluation of the solution.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please provide additional information about the required stresstest ? Is there already a sort of scenario to evaluate login performance that I shall reuse?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the https://github.com/goharbor/perf to do the stress test. cc @chlins can you help?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxgraustenzel-create Yes, you can use https://github.com/goharbor/perf to benchmark some harbor API, please refer to the README for usage, and feel free to reach out me if you have any question.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Chlins and Wang, this are the comparative test results of the performance tests of the role feature.

Custom roles — performance

Builds compared: upstream · fork, permissions from DB query (cache off) · fork, permissions via cache (L1 in-proc + L2 Redis).

Cache behavior

  • Warm-up: on the first permission check a background pass eagerly loads all roles + permissions into L1 (in-proc sync.Map) and L2 (Redis) — no cold-miss stampede.
  • Invalidation on change: any role create/update/delete bumps a Redis version key → every node drops its L1 within ~1 s (cross-node), and the changed role's L2 entry is deleted.
  • Invalidation on time: 30-min TTL on both L1 and L2 → self-heals out-of-band DB changes (e.g. direct SQL).

A cached entry is refreshed by (a) a role change anywhere (≤1 s) or (b) its 30-min TTL, whichever comes first.

Test setup

Bare-metal (32c / 31 GB), all services + Postgres + Redis as containers. Postgres tuned (max_connections=1024, shared_buffers=4 GB); DB snapshot restored and ANALYZEd before each scenario. Load: k6 (xk6-harbor), 500 VUs, 5000 iters/endpoint, session-cookie auth, warm-up pass per endpoint, 3 passes (mean reported). Metric: end-to-end request latency (ms); (%) = change vs upstream for the same endpoint. Upstream baseline is the exact commit the fork is based on, so the delta isolates the feature.

Admin workload — feature is free

A sysadmin skips the project role evaluator, so there is no role lookup to cache. All existing endpoints land within measurement noise of upstream, and cache vs no-cache is indistinguishable. (list-roles is a new endpoint, ~230 ms, with no upstream equivalent.)

Non-admin (project member) — the cache earns its keep

Every request evaluates the member's project role → a role_permission lookup. Here the cache matters. Pooled over built-in and custom roles (same result either way):

Role-evaluating endpoints (project-scoped)

Endpoint upstream from DB query via cache
get-project 275 356 (+30 %) 275 (0 %)
list-project-members 213 299 (+40 %) 217 (+2 %)
list-repositories 323 398 (+23 %) 312 (−4 %)
get-repository 174 272 (+56 %) 185 (+7 %)
list-artifacts 641 732 (+14 %) 660 (+3 %)
list-artifact-tags 243 334 (+38 %) 246 (+1 %)

DB lookup: +14–56 % vs upstream; cache: upstream parity.

Endpoints with no per-request role lookup — unaffected

Endpoint upstream from DB query via cache
login 275 270 (−2 %) 269 (−2 %)
get-v2 48 48 (+1 %) 47 (−2 %)
list-projects 941 907 (−4 %) 944 (0 %)
list-audit-logs 12852 10919 (−15 %) 12410 (−3 %)

Flat across all three — auth (login), registry token (get-v2), and the two list endpoints whose cost is the query/membership filter, not per-request role evaluation.

New endpoint

Endpoint upstream from DB query via cache
list-roles 101 (404) 286 (+182 %) 283 (+179 %)

list-roles has no upstream equivalent (fast 404). On the fork it is ~+180 % with or without the cache, because the list path (ctl.Listpopulate) reads each role's permissions directly from the DB and bypasses the cache — new-endpoint cost, and a candidate for a future optimization (it is a management endpoint, not a hot read path).

Takeaway

The custom-roles feature is upstream-equivalent with the cache on. The cache is not a micro-optimization: for real project members it removes a 10–55 % per-request penalty on common read endpoints by eliminating the role-permission DB query. Admins never hit that path, so for them the feature is free regardless.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my understanding, the upstream response/latency should equal the DB query. Why, then, are we seeing poor performance on the role-evaluation endpoints?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstream has only the buildin roles that are hardcoded. The DB implementation queries the permissions on every request from the DB which leads to poor performance. The cache implementation uses a cache (two layers) with a ttl of 1 second in order to mediate this. The performance of the cached version are similar to the upstream.

@maxgraustenzel-create maxgraustenzel-create Jun 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the comment from Vadim I did a new implementation ... now the redis cache is disabled by default and only the in memory cach is working by default. However redis cache can be enabled through environment variables.

The results are similar for the two caching solutions (cache off = db query on every request):

Endpoint upstream cache off L1 (default) L1+L2
get-project 213 246 (+15.9%) 216 (+1.6%) 210 (−1.2%)
list-project-members 174 203 (+17.1%) 172 (−0.7%) 169 (−2.8%)
list-repositories 288 350 (+21.3%) 285 (−0.9%) 292 (+1.1%)
get-repository 149 184 (+23.2%) 147 (−1.5%) 146 (−2.3%)
list-artifacts 611 649 (+6.3%) 599 (−1.9%) 599 (−2.0%)
list-artifact-tags 193 246 (+27.2%) 194 (+0.5%) 195 (+0.8%)
overall mean 271 313 (+15.4%) 269 (−0.8%) 268 (−1.1%)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxgraustenzel-create I would like to know what happens in a high-availability scenario where Harbor is deployed with multiple core instances (pods). If we only enable memory caching, what happens when requests are routed to different pods with inconsistent memory caches?

@maxgraustenzel-create maxgraustenzel-create Jun 25, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memory cache has by default a TTL of 1 second. In a cluster setup, on the instances where the change happened the cache is updated directly, On the other instances the requests that arrive for permissions update the cache if it is older than 1 second. The cache is updated with the new value from the DB.

Two environment variables Permission_cache_L1_TTL and Permission_cache_L2_TTL allow to further fine tune this behavior. Negative values in the environment variables disable the cache.

  • L1=1s, L2=-1 --> default config described above --> 1s stale, no redis
  • L1=-1, L2=-1 --> no cache, db query for permissions on every request --> no stale, heavy on DB
  • L1=-1, L2=30m --> use redis on every request, redis is invalidated on changes and aligned every 30min with database as autohealing --> no stale, heavy on Redis
  • L1=1s, L2=30m --> two levels of cache --> 1s stale, light on redis and on db

- **Requests:** No change (cached permissions)
- **Database:** Minimal load increase (one query per login)

**Version Compatibility:**

- Harbor instances without custom roles: No impact
- Harbor instances with custom roles: Rollback preserves data but makes custom roles inaccessible
- Upgrade path: Standard Harbor upgrade (no special steps)

## Implementation

**Current Status: ~60% Complete**

### Phase 1: Foundation (✅ Complete)
- Database schema design and migrations
- Core RBAC permission loading logic
- `role_permission` table integration

**Owner:** Max Graustenzel
**Timeline:** Completed

### Phase 2: API Layer (✅ Complete)
- `/api/v2.0/roles` endpoints (CRUD operations)
- `/api/v2.0/permissions` endpoint extension
- OpenAPI/Swagger specification

**Owner:** Max Graustenzel
**Timeline:** Completed

### Phase 3: UI Components (✅ Complete)
- System Administration → Roles management interface
- Role creation/edit wizard
- Permission selection interface
- Built-in vs custom role indicators

**Owner:** Max Graustenzel
**Timeline:** Completed

### Phase 4: Security Validation (🔄 In Progress - 30%)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we consider?

  • If a custom role includes ResourceRobot:ActionCreate, how do we prevent a user with this role from creating a robot account with permissions that exceed the user's custom role scope?
  • A user with role-management permissions within a project must not be allowed to assign custom roles that contain permissions higher than their own assigned role.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have implemented mechanisms that prevent privilege escalation (on frontend and backend).

  • Users can only create robots with less or equal permissions than they have. Where necessary i implemented a mapping between robot and role permissions.
  • Users can only assign roles with less or equal permissions than they have.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options for the cache

  • 0: -- no cache
  • 1.X — session-scope: permissions cached per user session.
  • 2.X — system-scope: permissions cached once per node, shared
    across all sessions and users.

Criteria

  • Request time — added per-request latency on role-evaluating endpoints
    (measured vs upstream where available).
  • Stale window — how long a role change can go unseen (cross-node propagation /
    revocation timeliness).
  • Redis impact — does the approach put Redis on the authz hot path?
# Approach (proposer) Request time Stale window Redis impact Verdict
0 DB every request (baseline) all auth +14–56 % none (always fresh) none Correct but too slow as default
1 session-scope cache
1.1 At login (initial, Max) session ~0 · basic +14–56 % until session ends none Withdrawn — long stale window
1.2 L1 session + opt-in L2 Redis (review) session ~0 · basic +14–56 % = L1 TTL; must be large (N sessions/node reload on expiry) none by default; opt-in L2, but N reads/node on expiry (1/session) Deferred — Harbor-wide perf follow-up
1.3 Login + session invalidation (Vad1mo) session ~0 · basic +14–56 % none (fresh or kickout) (relogin spike) Set aside — write-back race + reverse lookup
1.4 Session cache + Redis version-key (wy65701436) session ~0 · basic +14–56 % ~1 s (on version bump) read-path: version-key per request (+ reload spike) Rejected — Redis on authz path (#23335)
2 system-scope cache
2.1 Redis-only shared cache (considered) all auth: +1 Redis hop/request ~0 (delete on write) read-path: GET every request Not pursued — Redis on every request (cf. #19156/63/64)
2.2 L1 node + opt-in L2 Redis (chosen) all auth ~0 (parity; 0 hops) = L1 TTL (1s) none by default; opt-in L2, 1 read/node on expiry Implemented

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more question here: if Harbor has previously issued a v2 token to a Docker client to pull an image, and this token has a 30-minute expiration, but the user's role changes during these 30 minutes, is the old token still valid? Is there a potential risk of permission leakage here?

@maxgraustenzel-create maxgraustenzel-create Jun 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my understanding:

The v2 JWT tokens encode the access rights (repository:name:actions) for specific registry actions.

Harbor's authorization is evaluated only at token-issue time and frozen into the signed token's access claim. After issue, the token is validated statelessly by the registry (distribution).

As a result, changes made in Harbor afterward — removing a user from a project, downgrading their role, or editing the permissions of a (custom or built-in) role — are not reflected in tokens already issued.

They take effect on the next token negotiation, i.e. after the current token expires (token_expiration, default 30 min). If tighter bounds are needed for a sensitive deployment, lowering token_expiration shrinks the window.

Docker Registry v2 behavior is based on a standard and is identical across project removal, built-in role downgrades, and custom roles — custom roles neither introduce nor worsen it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I know this isn't specific to the new feature, just wanted to confirm if the underlying behavior had changed at all. Thanks for clarifying!

- Privilege escalation prevention (robot creation)
Comment thread
maxgraustenzel-create marked this conversation as resolved.
- Privilege escalation prevention (member assignment)
Comment thread
maxgraustenzel-create marked this conversation as resolved.
- Audit logging for all role operations
- UI security enhancements (disable invalid permissions/roles in forms)

**Owner:** Max Graustenzel
**Timeline:** 2 weeks

### Phase 5: Testing (🔄 In Progress - 10%)
- Unit tests (role CRUD, permission validation, cache behavior)
- Integration tests (API contracts, multi-user scenarios, migrations)
- Security tests (privilege escalation attempts, unauthorized access)
- E2E tests (complete workflows via UI)

**Owner:** Max Graustenzel
**Timeline:** 2 weeks

### Phase 6: Documentation (🔄 In Progress - 30%)
- User guide (creating and managing custom roles)
- Administrator guide (installation, migration, security)
- API documentation (code examples, migration guide)
- Design documentation (architecture, trade-offs)

**Owner:** Max Graustenzel
**Timeline:** 1 week

### Phase 7: Review & Polish (⏳ Not Started)
- Performance optimization
- Security audit
- Code review addressing feedback
- Release preparation

**Owner:** Max Graustenzel + Community Reviewers
**Timeline:** 2-3 weeks

**Estimated Timeline to Production Ready:** 6-10 weeks from current state

**Repository:**
- Fork: https://github.com/maxgraustenzel-create/harbor
- Branch: `origin/18124-custom-role-feature`

## Open Issues

### 1. Permission Granularity
**Question:** Is project-level permission granularity sufficient, or should we support repository-level permissions?

**Current Decision:** Project-level only (permissions apply to entire project, not individual repositories)
**Rationale:**
- Permissions are defined system-wide in `permission_policy` table
- Applied per-project via `project_member` role assignments
- Repository-level permissions would require different scope model and significant additional complexity
- Project-level covers 70%+ of use cases
- Repository-level permissions tracked separately in Issue #10159

**Open for Discussion:** Should repository-level permission assignment be part of this proposal or remain a separate future enhancement?

### 2. Global vs. Project-Scoped Custom Roles
**Question:** Should custom roles be system-wide (reusable across projects) or per-project?

**Current Decision:** System-wide roles, assigned per-project (matches built-in role model)
**Rationale:** Consistent with existing Harbor model, easier to manage
**Open for Discussion:** Per-project custom roles could be added later if needed

### 3. Role Templates
**Question:** Should we provide predefined custom role templates (e.g., "Read-Only Auditor", "CI/CD Bot Manager")?

**Current Decision:** No templates in initial release
**Rationale:** Keep initial scope focused, templates can be added based on community usage patterns
**Open for Discussion:** Community can contribute templates as documentation/examples

### 4. Built-in Role Modification Policy

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this as there's an unanimous conclusion from above.

**Question:** Should built-in roles be modifiable or immutable?

**Current Decision:** Modifiable (with tracking and reset capability)
**Rationale:** Maximum flexibility, modifications are tracked, can be reset to defaults
**Alternative:** Immutable built-in roles (forces use of custom roles for any changes)
**Open for Discussion:** Security-conscious organizations may prefer immutable built-in roles

### 5. Permission Change Propagation
**Question:** Should permission changes apply immediately or at next login?

**Current Decision:** Next login (session-scoped cache invalidation)
**Rationale:** Simpler implementation, minimal real-world impact (permissions rarely change mid-session)
**Alternative:** Real-time propagation (complex cache invalidation, performance overhead)
**Open for Discussion:** Real-time propagation could be added later if needed

---

**Feedback Welcome**

This proposal is open for community discussion. Please provide feedback on:
- Architecture and design approach
- Security model and concerns
- API design and compatibility
- UI/UX and usability
- Documentation clarity
- Implementation timeline
- Open issues and alternatives

**Contact:** Max Graustenzel (@maxgraustenzel-create)