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/api/handlers/projects/projects.py b/src/api/handlers/projects/projects.py index de17501cd..d92bfde66 100644 --- a/src/api/handlers/projects/projects.py +++ b/src/api/handlers/projects/projects.py @@ -49,14 +49,22 @@ def get(self): ''' Returns user's projects ''' - projects = g.db.execute_many_dict(""" - SELECT p.id, p.name, p.type, p.public, co.role AS userrole - FROM project p - INNER JOIN collaborator co - ON co.project_id = p.id - AND %s = co.user_id - ORDER BY p.name - """, [g.token['user']['id']]) + # Global tokens can see all projects + if g.token and g.token.get('type') == 'global': + projects = g.db.execute_many_dict(""" + SELECT p.id, p.name, p.type, p.public + FROM project p + ORDER BY p.name + """) + else: + projects = g.db.execute_many_dict(""" + SELECT p.id, p.name, p.type, p.public, co.role AS userrole + FROM project p + INNER JOIN collaborator co + ON co.project_id = p.id + AND %s = co.user_id + ORDER BY p.name + """, [g.token['user']['id']]) return projects 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/global_viewer_test.rego b/src/openpolicyagent/policies/global_viewer_test.rego new file mode 100644 index 000000000..446983ee9 --- /dev/null +++ b/src/openpolicyagent/policies/global_viewer_test.rego @@ -0,0 +1,159 @@ +package infrabox + +# ─── helpers ─────────────────────────────────────────────────────────────────── + +global_viewer_token = { + "type": "global", + "user": {"id": null, "role": "viewer"}, + "global_token": {"id": "00000000-0000-0000-0000-000000000001", + "scope_push": false, "scope_pull": true} +} + +user_viewer_token = { + "type": "user", + "user": {"id": "00000000-0000-0000-0000-000000000002", "role": "viewer"} +} + +# ─── global token: read-only allowed ─────────────────────────────────────────── + +test_global_viewer_can_get_admin_users { + authz with input as { + "method": "GET", + "path": ["api", "v1", "admin", "users"], + "token": global_viewer_token + } +} + +test_global_viewer_can_get_admin_clusters { + authz with input as { + "method": "GET", + "path": ["api", "v1", "admin", "clusters"], + "token": global_viewer_token + } +} + +test_global_viewer_can_get_projects_list { + authz with input as { + "method": "GET", + "path": ["api", "v1", "projects"], + "token": global_viewer_token + } +} + +test_global_viewer_can_get_project_by_id { + authz with input as { + "method": "GET", + "path": ["api", "v1", "projects", "00000000-0000-0000-0000-000000000099"], + "token": global_viewer_token + } +} + +# ─── global token: write MUST be denied ──────────────────────────────────────── + +test_global_viewer_cannot_post_projects { + not authz with input as { + "method": "POST", + "path": ["api", "v1", "projects"], + "token": global_viewer_token + } +} + +test_global_viewer_cannot_delete_project { + not authz with input as { + "method": "DELETE", + "path": ["api", "v1", "projects", "00000000-0000-0000-0000-000000000099"], + "token": global_viewer_token + } +} + +test_global_viewer_cannot_post_admin_users { + not authz with input as { + "method": "POST", + "path": ["api", "v1", "admin", "users"], + "token": global_viewer_token + } +} + +test_global_viewer_cannot_delete_admin_clusters { + not authz with input as { + "method": "DELETE", + "path": ["api", "v1", "admin", "clusters"], + "token": global_viewer_token + } +} + +# ─── user viewer role: read-only allowed ─────────────────────────────────────── + +test_user_viewer_can_get_admin_users { + authz with input as { + "method": "GET", + "path": ["api", "v1", "admin", "users"], + "token": user_viewer_token + } +} + +test_user_viewer_can_get_projects_list { + authz with input as { + "method": "GET", + "path": ["api", "v1", "projects"], + "token": user_viewer_token + } +} + +# ─── user viewer role: write MUST be denied ──────────────────────────────────── + +test_user_viewer_cannot_post_admin_users { + not authz with input as { + "method": "POST", + "path": ["api", "v1", "admin", "users"], + "token": user_viewer_token + } +} + +test_user_viewer_cannot_post_projects { + not authz with input as { + "method": "POST", + "path": ["api", "v1", "projects"], + "token": user_viewer_token + } +} + +test_regular_user_can_post_projects { + authz with input as { + "method": "POST", + "path": ["api", "v1", "projects"], + "token": { + "type": "user", + "user": {"id": "00000000-0000-0000-0000-000000000004", "role": "user"} + } + } +} + +test_user_viewer_cannot_delete_project { + not authz with input as { + "method": "DELETE", + "path": ["api", "v1", "projects", "00000000-0000-0000-0000-000000000099"], + "token": user_viewer_token + } +} + +# ─── global-tokens endpoint: only admin can manage ───────────────────────────── + +test_global_viewer_cannot_post_global_tokens { + not authz with input as { + "method": "POST", + "path": ["api", "v1", "admin", "global-tokens"], + "token": global_viewer_token + } +} + +test_admin_can_post_global_tokens { + authz with input as { + "method": "POST", + "path": ["api", "v1", "admin", "global-tokens"], + "token": { + "type": "user", + "user": {"id": "00000000-0000-0000-0000-000000000003", "role": "admin"} + } + } +} diff --git a/src/openpolicyagent/policies/projects_projects.rego b/src/openpolicyagent/policies/projects_projects.rego index 50728d8cc..507aa0816 100644 --- a/src/openpolicyagent/policies/projects_projects.rego +++ b/src/openpolicyagent/policies/projects_projects.rego @@ -43,6 +43,7 @@ allow { api.method = "POST" api.path = ["api", "v1", "projects"] api.token.type = "user" + api.token.user.role != "viewer" } allow { @@ -95,3 +96,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 = {