From f3f932ba17c4ba757d632226a8f7a7fd38d9b863 Mon Sep 17 00:00:00 2001 From: Yuan Huang Date: Fri, 20 Mar 2026 18:44:53 +0800 Subject: [PATCH] feat: add global viewer role and global token support - 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 --- src/api/handlers/admin/__init__.py | 1 + src/api/handlers/admin/global_tokens.py | 85 +++++++++++++++++++ src/db/migrations/00045.sql | 7 ++ src/openpolicyagent/policies/admin.rego | 26 +++++- .../policies/projects_projects.rego | 45 ++++++++++ src/pyinfraboxutils/ibflask.py | 29 +++++++ src/pyinfraboxutils/token.py | 9 ++ 7 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/api/handlers/admin/global_tokens.py create mode 100644 src/db/migrations/00045.sql diff --git a/src/api/handlers/admin/__init__.py b/src/api/handlers/admin/__init__.py index cb3598a52..9eb7bcb5d 100644 --- a/src/api/handlers/admin/__init__.py +++ b/src/api/handlers/admin/__init__.py @@ -1,4 +1,5 @@ import api.handlers.admin.projects import api.handlers.admin.users import api.handlers.admin.clusters +import api.handlers.admin.global_tokens diff --git a/src/api/handlers/admin/global_tokens.py b/src/api/handlers/admin/global_tokens.py new file mode 100644 index 000000000..f66a1cf12 --- /dev/null +++ b/src/api/handlers/admin/global_tokens.py @@ -0,0 +1,85 @@ +import uuid + +from flask import g, abort, request +from flask_restx import Resource, fields +from pyinfraboxutils.ibflask import OK +from pyinfraboxutils.ibrestplus import api +from pyinfraboxutils.token import encode_global_token + +global_token_create_model = api.model('GlobalTokenCreate', { + 'description': fields.String(required=True), + 'scope_push': fields.Boolean(required=False, default=False), + 'scope_pull': fields.Boolean(required=False, default=True), +}) + +@api.route('/api/v1/admin/global-tokens', doc=False) +class GlobalTokens(Resource): + + def get(self): + """List all global tokens (admin only)""" + tokens = g.db.execute_many_dict(''' + SELECT id, description, scope_push, scope_pull + FROM global_token + ORDER BY description + ''') + return tokens + + @api.expect(global_token_create_model, validate=True) + def post(self): + """Create a new global token (admin only)""" + if g.token['user']['role'] != 'admin': + abort(403, "creating global tokens is only allowed for admin users") + + body = request.get_json() + token_id = str(uuid.uuid4()) + description = body['description'] + scope_push = body.get('scope_push', False) + scope_pull = body.get('scope_pull', True) + + g.db.execute(''' + INSERT INTO global_token (id, description, scope_push, scope_pull) + VALUES (%s, %s, %s, %s) + ''', [token_id, description, scope_push, scope_pull]) + g.db.commit() + + token = encode_global_token(token_id) + + return { + 'id': token_id, + 'token': token, + 'description': description, + 'scope_push': scope_push, + 'scope_pull': scope_pull, + } + + +@api.route('/api/v1/admin/global-tokens/', doc=False) +class GlobalToken(Resource): + + def get(self, token_id): + """Get a specific global token (admin only)""" + token = g.db.execute_one_dict(''' + SELECT id, description, scope_push, scope_pull + FROM global_token + WHERE id = %s + ''', [token_id]) + + if not token: + abort(404, "Global token not found") + + return token + + def delete(self, token_id): + """Delete a global token (admin only)""" + if g.token['user']['role'] != 'admin': + abort(403, "deleting global tokens is only allowed for admin users") + + num = g.db.execute(''' + DELETE FROM global_token WHERE id = %s + ''', [token_id]) + g.db.commit() + + if num == 0: + abort(404, "Global token not found") + + return OK("OK") \ No newline at end of file diff --git a/src/db/migrations/00045.sql b/src/db/migrations/00045.sql new file mode 100644 index 000000000..8ca062910 --- /dev/null +++ b/src/db/migrations/00045.sql @@ -0,0 +1,7 @@ +CREATE TABLE "global_token" ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + description VARCHAR(255) NOT NULL, + scope_push BOOLEAN DEFAULT FALSE NOT NULL, + scope_pull BOOLEAN DEFAULT TRUE NOT NULL, + PRIMARY KEY (id) +); \ No newline at end of file diff --git a/src/openpolicyagent/policies/admin.rego b/src/openpolicyagent/policies/admin.rego index f68da726b..e64378c63 100644 --- a/src/openpolicyagent/policies/admin.rego +++ b/src/openpolicyagent/policies/admin.rego @@ -3,7 +3,7 @@ package infrabox # HTTP API request import input as api -user_roles = {"user": 10, "devops": 20, "admin": 30} +user_roles = {"viewer": 15, "user": 10, "devops": 20, "admin": 30} default authz = false @@ -20,6 +20,30 @@ allow { user_roles[api.token.user.role] >= 20 } +# Allow viewer role GET access to all admin endpoints +allow { + api.method = "GET" + api.token.type = "user" + api.token.user.role = "viewer" +} + +# Allow global token (viewer) GET access to all admin endpoints +allow { + api.method = "GET" + api.token.type = "global" + api.token.user.role = "viewer" +} + +# Allow admin access to manage global tokens +allow { + api.path[0] = "api" + api.path[1] = "v1" + api.path[2] = "admin" + api.path[3] = "global-tokens" + api.token.type = "user" + user_roles[api.token.user.role] >= 30 +} + # Allow GET access to /api/v1/admin/clusters for users logged in allow { diff --git a/src/openpolicyagent/policies/projects_projects.rego b/src/openpolicyagent/policies/projects_projects.rego index 50728d8cc..52b228aab 100644 --- a/src/openpolicyagent/policies/projects_projects.rego +++ b/src/openpolicyagent/policies/projects_projects.rego @@ -95,3 +95,48 @@ allow { api.token.type = "user" projects_projects_owner([api.token.user.id, project]) } + +# Allow global token (viewer) GET access to all projects list +allow { + api.method = "GET" + api.path = ["api", "v1", "projects"] + api.token.type = "global" +} + +# Allow global token (viewer) GET access to specific project by id +allow { + api.method = "GET" + api.path = ["api", "v1", "projects", project] + api.token.type = "global" +} + +# Allow global token (viewer) GET access to project by name +allow { + api.method = "GET" + array.slice(api.path, 0, 4) = ["api", "v1", "projects", "name"] + api.token.type = "global" +} + +# Allow viewer user role GET access to all projects list +allow { + api.method = "GET" + api.path = ["api", "v1", "projects"] + api.token.type = "user" + api.token.user.role = "viewer" +} + +# Allow viewer user role GET access to specific project by id +allow { + api.method = "GET" + api.path = ["api", "v1", "projects", project] + api.token.type = "user" + api.token.user.role = "viewer" +} + +# Allow viewer user role GET access to project by name +allow { + api.method = "GET" + array.slice(api.path, 0, 4) = ["api", "v1", "projects", "name"] + api.token.type = "user" + api.token.user.role = "viewer" +} diff --git a/src/pyinfraboxutils/ibflask.py b/src/pyinfraboxutils/ibflask.py index 06c59cf64..bc3dc2671 100644 --- a/src/pyinfraboxutils/ibflask.py +++ b/src/pyinfraboxutils/ibflask.py @@ -194,6 +194,10 @@ def normalize_token(token): if not validate_project_token(token): return None + # Validate global token + elif token["type"] == "global": + return validate_global_token(token) + return token def enrich_job_token(token): @@ -230,6 +234,31 @@ def validate_user_token(token): token['user']['role'] = u[1] return token +def validate_global_token(token): + if not ("id" in token and validate_uuid(token['id'])): + return None + + r = g.db.execute_one(''' + SELECT id, description, scope_push, scope_pull FROM global_token + WHERE id = %s + ''', [token['id']]) + if not r: + logger.warn('global token not valid') + return None + + token['global_token'] = { + 'id': r[0], + 'description': r[1], + 'scope_push': r[2], + 'scope_pull': r[3], + } + # Global tokens act as viewer role + token['user'] = { + 'id': None, + 'role': 'viewer', + } + return token + def validate_project_token(token): if not ("project" in token and "id" in token['project'] and validate_uuid(token['project']['id']) and "id" in token and validate_uuid(token['id'])): diff --git a/src/pyinfraboxutils/token.py b/src/pyinfraboxutils/token.py index e34d9851d..fe390fae9 100644 --- a/src/pyinfraboxutils/token.py +++ b/src/pyinfraboxutils/token.py @@ -29,6 +29,15 @@ def encode_project_token(token_id, project_id, name): return jwt.encode(data, key=s.read(), algorithm='RS256') +def encode_global_token(token_id): + with open(private_key_path) as s: + data = { + 'id': token_id, + 'type': 'global' + } + + return jwt.encode(data, key=s.read(), algorithm='RS256') + def encode_job_token(job_id): with open(private_key_path) as s: data = {