Skip to content

Commit e5ef97d

Browse files
sap-yuanYuan Huang
authored andcommitted
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
1 parent f8fcb19 commit e5ef97d

7 files changed

Lines changed: 201 additions & 1 deletion

File tree

src/api/handlers/admin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import api.handlers.admin.projects
22
import api.handlers.admin.users
33
import api.handlers.admin.clusters
4+
import api.handlers.admin.global_tokens
45

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import uuid
2+
3+
from flask import g, abort, request
4+
from flask_restx import Resource, fields
5+
from pyinfraboxutils.ibflask import OK
6+
from pyinfraboxutils.ibrestplus import api
7+
from pyinfraboxutils.token import encode_global_token
8+
9+
global_token_create_model = api.model('GlobalTokenCreate', {
10+
'description': fields.String(required=True),
11+
'scope_push': fields.Boolean(required=False, default=False),
12+
'scope_pull': fields.Boolean(required=False, default=True),
13+
})
14+
15+
@api.route('/api/v1/admin/global-tokens', doc=False)
16+
class GlobalTokens(Resource):
17+
18+
def get(self):
19+
"""List all global tokens (admin only)"""
20+
tokens = g.db.execute_many_dict('''
21+
SELECT id, description, scope_push, scope_pull
22+
FROM global_token
23+
ORDER BY description
24+
''')
25+
return tokens
26+
27+
@api.expect(global_token_create_model, validate=True)
28+
def post(self):
29+
"""Create a new global token (admin only)"""
30+
if g.token['user']['role'] != 'admin':
31+
abort(403, "creating global tokens is only allowed for admin users")
32+
33+
body = request.get_json()
34+
token_id = str(uuid.uuid4())
35+
description = body['description']
36+
scope_push = body.get('scope_push', False)
37+
scope_pull = body.get('scope_pull', True)
38+
39+
g.db.execute('''
40+
INSERT INTO global_token (id, description, scope_push, scope_pull)
41+
VALUES (%s, %s, %s, %s)
42+
''', [token_id, description, scope_push, scope_pull])
43+
g.db.commit()
44+
45+
token = encode_global_token(token_id)
46+
47+
return {
48+
'id': token_id,
49+
'token': token,
50+
'description': description,
51+
'scope_push': scope_push,
52+
'scope_pull': scope_pull,
53+
}
54+
55+
56+
@api.route('/api/v1/admin/global-tokens/<token_id>', doc=False)
57+
class GlobalToken(Resource):
58+
59+
def get(self, token_id):
60+
"""Get a specific global token (admin only)"""
61+
token = g.db.execute_one_dict('''
62+
SELECT id, description, scope_push, scope_pull
63+
FROM global_token
64+
WHERE id = %s
65+
''', [token_id])
66+
67+
if not token:
68+
abort(404, "Global token not found")
69+
70+
return token
71+
72+
def delete(self, token_id):
73+
"""Delete a global token (admin only)"""
74+
if g.token['user']['role'] != 'admin':
75+
abort(403, "deleting global tokens is only allowed for admin users")
76+
77+
num = g.db.execute('''
78+
DELETE FROM global_token WHERE id = %s
79+
''', [token_id])
80+
g.db.commit()
81+
82+
if num == 0:
83+
abort(404, "Global token not found")
84+
85+
return OK("OK")

src/db/migrations/00045.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE "global_token" (
2+
id uuid DEFAULT gen_random_uuid() NOT NULL,
3+
description VARCHAR(255) NOT NULL,
4+
scope_push BOOLEAN DEFAULT FALSE NOT NULL,
5+
scope_pull BOOLEAN DEFAULT TRUE NOT NULL,
6+
PRIMARY KEY (id)
7+
);

src/openpolicyagent/policies/admin.rego

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package infrabox
33
# HTTP API request
44
import input as api
55

6-
user_roles = {"user": 10, "devops": 20, "admin": 30}
6+
user_roles = {"viewer": 15, "user": 10, "devops": 20, "admin": 30}
77

88
default authz = false
99

@@ -20,6 +20,30 @@ allow {
2020
user_roles[api.token.user.role] >= 20
2121
}
2222

23+
# Allow viewer role GET access to all admin endpoints
24+
allow {
25+
api.method = "GET"
26+
api.token.type = "user"
27+
api.token.user.role = "viewer"
28+
}
29+
30+
# Allow global token (viewer) GET access to all admin endpoints
31+
allow {
32+
api.method = "GET"
33+
api.token.type = "global"
34+
api.token.user.role = "viewer"
35+
}
36+
37+
# Allow admin access to manage global tokens
38+
allow {
39+
api.path[0] = "api"
40+
api.path[1] = "v1"
41+
api.path[2] = "admin"
42+
api.path[3] = "global-tokens"
43+
api.token.type = "user"
44+
user_roles[api.token.user.role] >= 30
45+
}
46+
2347

2448
# Allow GET access to /api/v1/admin/clusters for users logged in
2549
allow {

src/openpolicyagent/policies/projects_projects.rego

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,48 @@ allow {
9595
api.token.type = "user"
9696
projects_projects_owner([api.token.user.id, project])
9797
}
98+
99+
# Allow global token (viewer) GET access to all projects list
100+
allow {
101+
api.method = "GET"
102+
api.path = ["api", "v1", "projects"]
103+
api.token.type = "global"
104+
}
105+
106+
# Allow global token (viewer) GET access to specific project by id
107+
allow {
108+
api.method = "GET"
109+
api.path = ["api", "v1", "projects", project]
110+
api.token.type = "global"
111+
}
112+
113+
# Allow global token (viewer) GET access to project by name
114+
allow {
115+
api.method = "GET"
116+
array.slice(api.path, 0, 4) = ["api", "v1", "projects", "name"]
117+
api.token.type = "global"
118+
}
119+
120+
# Allow viewer user role GET access to all projects list
121+
allow {
122+
api.method = "GET"
123+
api.path = ["api", "v1", "projects"]
124+
api.token.type = "user"
125+
api.token.user.role = "viewer"
126+
}
127+
128+
# Allow viewer user role GET access to specific project by id
129+
allow {
130+
api.method = "GET"
131+
api.path = ["api", "v1", "projects", project]
132+
api.token.type = "user"
133+
api.token.user.role = "viewer"
134+
}
135+
136+
# Allow viewer user role GET access to project by name
137+
allow {
138+
api.method = "GET"
139+
array.slice(api.path, 0, 4) = ["api", "v1", "projects", "name"]
140+
api.token.type = "user"
141+
api.token.user.role = "viewer"
142+
}

src/pyinfraboxutils/ibflask.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ def normalize_token(token):
194194
if not validate_project_token(token):
195195
return None
196196

197+
# Validate global token
198+
elif token["type"] == "global":
199+
return validate_global_token(token)
200+
197201
return token
198202

199203
def enrich_job_token(token):
@@ -230,6 +234,31 @@ def validate_user_token(token):
230234
token['user']['role'] = u[1]
231235
return token
232236

237+
def validate_global_token(token):
238+
if not ("id" in token and validate_uuid(token['id'])):
239+
return None
240+
241+
r = g.db.execute_one('''
242+
SELECT id, description, scope_push, scope_pull FROM global_token
243+
WHERE id = %s
244+
''', [token['id']])
245+
if not r:
246+
logger.warn('global token not valid')
247+
return None
248+
249+
token['global_token'] = {
250+
'id': r[0],
251+
'description': r[1],
252+
'scope_push': r[2],
253+
'scope_pull': r[3],
254+
}
255+
# Global tokens act as viewer role
256+
token['user'] = {
257+
'id': None,
258+
'role': 'viewer',
259+
}
260+
return token
261+
233262
def validate_project_token(token):
234263
if not ("project" in token and "id" in token['project'] and validate_uuid(token['project']['id'])
235264
and "id" in token and validate_uuid(token['id'])):

src/pyinfraboxutils/token.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ def encode_project_token(token_id, project_id, name):
2929

3030
return jwt.encode(data, key=s.read(), algorithm='RS256')
3131

32+
def encode_global_token(token_id):
33+
with open(private_key_path) as s:
34+
data = {
35+
'id': token_id,
36+
'type': 'global'
37+
}
38+
39+
return jwt.encode(data, key=s.read(), algorithm='RS256')
40+
3241
def encode_job_token(job_id):
3342
with open(private_key_path) as s:
3443
data = {

0 commit comments

Comments
 (0)