Skip to content

feat: add global viewer role and global token support#596

Open
sap-yuan wants to merge 20 commits into
masterfrom
feat/global-viewer-role
Open

feat: add global viewer role and global token support#596
sap-yuan wants to merge 20 commits into
masterfrom
feat/global-viewer-role

Conversation

@sap-yuan
Copy link
Copy Markdown
Collaborator

  • 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

@sap-yuan sap-yuan self-assigned this Mar 20, 2026
@sap-yuan sap-yuan requested a review from liuwei08 March 23, 2026 08:53
@sap-yuan sap-yuan force-pushed the feat/global-viewer-role branch from f3f932b to 7c686f2 Compare April 22, 2026 02:02
Copy link
Copy Markdown
Contributor

@liuwei08 liuwei08 left a comment

Choose a reason for hiding this comment

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

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 enforces user_id ownership

Should fix:

  • viewer role level 15 is higher than user 10 — verify no unintended privilege escalation via level comparisons
  • Hardcoded role: viewer makes scope_push column dead code — clarify or remove
  • Split 00045 + 00046 migrations 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

Comment thread src/pyinfraboxutils/ibflask.py Outdated
token = getattr(g, 'token', None)
if not token or token.get('type') != 'global':
return response
if response.status_code >= 400:
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.

[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',
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.

[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?
}

Comment thread src/openpolicyagent/policies/admin.rego Outdated
import input as api

user_roles = {"user": 10, "devops": 20, "admin": 30}
user_roles = {"viewer": 15, "user": 10, "devops": 20, "admin": 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.

[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"
}
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.

[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.

Comment thread src/db/migrations/00045.sql Outdated
scope_push BOOLEAN DEFAULT FALSE NOT NULL,
scope_pull BOOLEAN DEFAULT TRUE NOT NULL,
PRIMARY KEY (id)
); No newline at end of file
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.

[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.

Comment thread src/db/migrations/00046.sql Outdated
);

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);
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.

[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" (
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.

[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

Comment thread infrabox/local-dev/Makefile Outdated
@@ -0,0 +1,20 @@
COMPOSE = DOCKER_BUILDKIT=0 COMPOSE_DOCKER_CLI_BUILD=0 \
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.

[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.

sap-yuan and others added 19 commits May 15, 2026 17:02
- 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)
@sap-yuan sap-yuan force-pushed the feat/global-viewer-role branch from 07e024b to 7cbc155 Compare May 15, 2026 09:14
Copy link
Copy Markdown
Contributor

@liuwei08 liuwei08 left a comment

Choose a reason for hiding this comment

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

All previous "Must Fix" and "Should Fix" items have been addressed:

  • ✅ Audit log now records all responses (including 4xx/5xx)
  • ✅ DELETE handler enforces user_id ownership via SQL (AND user_id = %s)
  • viewer role level corrected to 5 (below user at 10)
  • ✅ Migrations 00045 + 00046 consolidated into a single atomic DDL
  • expires_at token 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_push is still stored but not yet enforced (intent documented in comment — fine for now)
  • global_token_access_log pruning 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants