feat: add global viewer role and global token support#596
Conversation
sap-yuan
commented
Mar 20, 2026
- Add 'viewer' role (level 15) to OPA admin policy
- Allow viewer role GET-only access to all resources
- Add global_token database table (migration 00045)
- Add encode_global_token() in token.py
- Support 'global' token type in ibflask.py normalize_token()
- Add global token CRUD API endpoints under /admin/global-tokens
- Update OPA projects policy for global token read access
f3f932b to
7c686f2
Compare
liuwei08
left a comment
There was a problem hiding this comment.
Overall the feature direction is sound and the OPA test coverage is a good sign. However there are several issues that need to be addressed before merging:
Must fix:
- Audit log silently drops failed requests (4xx/5xx) — limits security observability
- Confirm
DELETE /user/global-tokens/{id}handler enforcesuser_idownership
Should fix:
viewerrole level 15 is higher thanuser10 — verify no unintended privilege escalation via level comparisons- Hardcoded
role: viewermakesscope_pushcolumn dead code — clarify or remove - Split
00045+00046migrations into one atomic DDL
Suggestions:
- Add token expiration support (
expires_at) - Add a retention policy for
global_token_access_log - Move the
infrabox/local-dev/tooling into a separate PR
| token = getattr(g, 'token', None) | ||
| if not token or token.get('type') != 'global': | ||
| return response | ||
| if response.status_code >= 400: |
There was a problem hiding this comment.
[Security] Audit log silently drops failed requests
Skipping 4xx/5xx responses means the audit log cannot be used to detect permission probing or brute-force attempts. Successful-only logs are misleading for security auditors.
Consider logging at least 401 and 403 responses, or removing this filter entirely and logging all requests regardless of status code:
# Before: silently skip errors
if response.status_code >= 400:
return response
# After: log everything, including failures
# (or at minimum: if response.status_code in (401, 403): log and return)| # Token is scoped to its owner; viewer role enforces read-only access | ||
| token['user'] = { | ||
| 'id': r['user_id'], | ||
| 'role': 'viewer', |
There was a problem hiding this comment.
[Design] Hardcoded viewer role ignores scope_push
The DB schema stores scope_push per token, but here the role is always hardcoded to 'viewer' regardless of the token's actual scope_push value. This means even if a global token has scope_push=True, it will always be treated as read-only.
Is this intentional? If so, the scope_push column in the global_token table is dead code and should be removed. If not, the role assignment needs to reflect the actual scope:
token['user'] = {
'id': r['user_id'],
'role': 'viewer', # always viewer regardless of scope_push — is this intended?
}| import input as api | ||
|
|
||
| user_roles = {"user": 10, "devops": 20, "admin": 30} | ||
| user_roles = {"viewer": 15, "user": 10, "devops": 20, "admin": 30} |
There was a problem hiding this comment.
[Logic] viewer role level (15) is higher than user (10) — verify comparison direction
Assigning viewer level 15 (above user 10) is counterintuitive for a read-only role. If any policy uses user_roles[...] >= some_threshold to grant access, viewer will inadvertently pass checks that a regular user would not.
Please confirm that all policy rules relying on role level comparisons behave correctly with viewer = 15. If viewer is intended to be the least-privileged role, consider assigning it a level below user, e.g. 5.
| api.method = "DELETE" | ||
| api.path = ["api", "v1", "user", "global-tokens", _] | ||
| api.token.type = "user" | ||
| } |
There was a problem hiding this comment.
[Security] OPA policy for DELETE does not enforce ownership
The OPA rule allows any logged-in user to DELETE any token by path parameter alone, with no user_id ownership check at the policy level. The actual ownership enforcement must happen entirely in the handler.
Please verify that UserGlobalToken.delete() in global_tokens.py always includes AND user_id = %s in its SQL query. If the handler ever skips that check, any user could revoke another user's token. A defence-in-depth approach would add an ownership assertion to this OPA rule as well.
| scope_push BOOLEAN DEFAULT FALSE NOT NULL, | ||
| scope_pull BOOLEAN DEFAULT TRUE NOT NULL, | ||
| PRIMARY KEY (id) | ||
| ); No newline at end of file |
There was a problem hiding this comment.
[Migration] Consider merging 00045 and 00046 into a single migration
00045.sql creates global_token without user_id, and 00046.sql immediately ALTER TABLEs it to add user_id. If 00046.sql fails during deployment, the DB is left with an ownerless global_token table that has no foreign key constraint — any token inserted between the two migrations would have no owner.
Suggested fix: consolidate both into a single migration that creates the table with all final columns, including user_id uuid REFERENCES "user"(id) ON DELETE CASCADE NOT NULL.
| ); | ||
|
|
||
| CREATE INDEX idx_gtal_token_id ON global_token_access_log(token_id); | ||
| CREATE INDEX idx_gtal_accessed_at ON global_token_access_log(accessed_at); |
There was a problem hiding this comment.
[Ops] No retention policy for global_token_access_log
The access log table has no TTL, partition, or cleanup job. On a busy instance this table will grow indefinitely. Consider either:
- A scheduled job (e.g.
DELETE FROM global_token_access_log WHERE accessed_at < NOW() - INTERVAL '90 days') - Table partitioning by
accessed_at - Or at minimum, documenting that operators are responsible for periodic pruning
| @@ -0,0 +1,7 @@ | |||
| CREATE TABLE "global_token" ( | |||
There was a problem hiding this comment.
[Design] No token expiration support
Global tokens are permanent once created — they remain valid until explicitly deleted. For long-lived viewer tokens this is a security risk (e.g. if a user leaves the organization).
Consider adding an optional expires_at TIMESTAMP column and checking it in validate_global_token():
if r['expires_at'] and r['expires_at'] < datetime.utcnow():
logger.warning('global token expired')
return None| @@ -0,0 +1,20 @@ | |||
| COMPOSE = DOCKER_BUILDKIT=0 COMPOSE_DOCKER_CLI_BUILD=0 \ | |||
There was a problem hiding this comment.
[Scope] Local dev stack is unrelated to feat/global-viewer-role
The infrabox/local-dev/ directory (Makefile, docker-compose, seed.sql, README) is a standalone developer tooling addition that has no functional dependency on the global viewer role feature. Bundling it here makes the PR harder to review and will complicate bisecting if either change introduces a regression.
Please consider splitting this into a separate PR.
- Add 'viewer' role (level 15) to OPA admin policy - Allow viewer role GET-only access to all resources - Add global_token database table (migration 00045) - Add encode_global_token() in token.py - Support 'global' token type in ibflask.py normalize_token() - Add global token CRUD API endpoints under /admin/global-tokens - Update OPA projects policy for global token read access
Each user can now create, revoke, and inspect personal viewer tokens (global tokens) scoped to their own projects. Key changes: Backend: - Migration 00046: add user_id + created_at to global_token; add global_token_access_log table for per-request audit trail - ibflask: validate_global_token now sets real user_id so project queries are collaborator-filtered; after_request hook writes access log - New handler at /api/v1/user/global-tokens (CRUD + access log) for any logged-in user; admin/global-tokens simplified to read-only audit - projects.py: removed "global token sees all projects" branch; both user and global tokens now use the same collaborator-scoped query - OPA: user.rego rules for personal token management; admin.rego drops blanket global-token admin access; projects_projects.rego enforces collaborator check for global token project access Frontend: - UserGlobalTokens.vue: personal token page (create/revoke/access log) - AdminGlobalTokens.vue: rewritten as admin-only read-only audit view - UserTokenService.js: user-level token API service - Nav: "My Tokens" added for all users; Admin menu shows "Tokens (Audit)"
Add a second section to the My Tokens page that lists all project-level tokens grouped by project, for projects where the user has admin rights. Reuses existing per-project token endpoints — no backend changes needed.
Covers list, create, delete, and access-log endpoints including ownership enforcement and unauthorized access checks. Also adds TRUNCATE for the new global_token tables to the shared setUp so FK constraints don't interfere with other test classes.
… tests Use a single TRUNCATE statement for global_token_access_log and global_token to satisfy PostgreSQL FK constraint requirements. Extract TOKEN_URL and ACCESS_LOG_URL class constants to remove duplicated format strings.
PostgreSQL requires all FK-linked tables to appear in the same TRUNCATE statement. global_token references "user", and global_token_access_log references global_token, so all three must be truncated together.
PostgreSQL blocks TRUNCATE on any table that is referenced by FK constraints in other tables, unless all referencing tables are truncated in the same statement. Collapsed all 14 separate TRUNCATE calls into one to cover the full dependency graph: global_token chain, collaborator, auth_token, jobs, builds, commits, repositories, etc.
…e format
Three bugs in the global token API handler:
1. g.db.execute() returns None, so `if num == 0` never triggered — fixed by
selecting first to verify ownership before deleting
2. execute_many_dict returns raw datetime objects — fixed with _serialize_row
helper that converts datetime to ISO strings
3. abort(404) returns Flask error format without 'status' key — fixed by
returning explicit {'status': 404} dict with HTTP 404 status code
…e Fonts store.js referenced deleteAdminGlobalToken in the mutations map but the function was never defined, causing a ReferenceError at module load time that crashed the Vue app before it could mount (blank page, no API calls). Also load Google Fonts asynchronously (media="print" + onload) to avoid render-blocking on SAP internal networks where fonts.googleapis.com is unreachable.
- Add webpack proxyTable to forward /api to localhost:8090 during dev - Remove Read/Write scope checkboxes; global tokens are read-only
Global tokens with scope_pull are now allowed by OPA to list projects, but the handler was still using INNER JOIN collaborator which filtered to only the token creator's projects. Add a dedicated query path that returns all projects when the caller holds a global pull token.
Account-related items should appear before the logout action.
Design intent (per OPA test comment) is that the Python side filters by collaborator membership — global tokens see only projects where the token creator is a collaborator, same as regular users.
DictCursor returns DictRow objects; using column names is explicit and avoids potential integer-index issues with UUID fields.
- Migration 00047: add expires_at column, default 1 year from created_at - validate_global_token: reject tokens past their expires_at - Create endpoint: accepts expires_days (1–3650, default 365) - List endpoint: returns expires_at - UI: expiry input on create form; Expires column in list with warning highlight when ≤30 days remaining - Audit log: also record failed (4xx/5xx) requests - Delete: simplify to single DELETE...RETURNING query
…scope_push comment - Lower viewer role level from 15 to 5 in admin.rego so viewer is correctly the least-privileged role (below user=10) - Consolidate migrations 00045/00046/00047 into a single 00045.sql that creates global_token with all final columns (user_id NOT NULL, expires_at NOT NULL) and the access_log table with a retention note - Clarify that scope_push is stored for future use but does not change OPA authorization; global tokens are always read-only (viewer role)
07e024b to
7cbc155
Compare
liuwei08
left a comment
There was a problem hiding this comment.
All previous "Must Fix" and "Should Fix" items have been addressed:
- ✅ Audit log now records all responses (including 4xx/5xx)
- ✅ DELETE handler enforces
user_idownership via SQL (AND user_id = %s) - ✅
viewerrole level corrected to 5 (belowuserat 10) - ✅ Migrations 00045 + 00046 consolidated into a single atomic DDL
- ✅
expires_attoken expiration implemented in schema and validation logic - ✅
infrabox/local-dev/tooling split into a separate PR
Two minor items remain as acceptable follow-ups:
scope_pushis still stored but not yet enforced (intent documented in comment — fine for now)global_token_access_logpruning relies on operator runbook rather than automation
One additional note for a future PR: the normalize_token() fallthrough return token for unknown token types is a minor security hardening opportunity — consider returning None instead.