Skip to content

Commit cb33897

Browse files
authored
AAP-50614 Add a methods for user claims serialization and hashing (#787)
## Description Up until this point, the DAB app jwt_consumer has housed logic for _saving_ user claims to the RBAC models in the local database. We have planned a change which will modify this contract. A service will still get serialized claims from the resource server and save them by existing jwt_consumer logic, but when this happens we will store a hash of those claims (producible from the claims data, no DB interaction needed) in the local cache. Then, we will cross-check against this hash for future requests and do nothing if it matches. This will allow us to move the transmission of the cache data to a new mechanism which avoids the header size limitations that plague JWT usage right now. This just does this first part - move _generation_ of claims data into DAB. Before this we _consumed_ the claims data, and this remains unchanged. Additionally, we add some simple logic to _hash_ the claims data and test that the hash is consistent.
1 parent 1721326 commit cb33897

File tree

4 files changed

+1123
-0
lines changed

4 files changed

+1123
-0
lines changed

ansible_base/rbac/claims.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import hashlib
2+
import json
3+
from collections import defaultdict
4+
from typing import Union
5+
6+
from django.apps import apps
7+
from django.conf import settings
8+
from django.db.models import F, Model, OuterRef, QuerySet
9+
10+
from ansible_base.lib.utils.auth import get_team_model
11+
12+
from .models.content_type import DABContentType
13+
from .models.role import RoleDefinition
14+
15+
16+
def get_user_object_roles(user: Model) -> QuerySet:
17+
"""Get all object-scoped role assignments for a user with resource metadata.
18+
19+
This function retrieves role assignments that are scoped to specific objects
20+
(not global roles) and joins them with resource registry data to include
21+
ansible_id and name values. Only JWT-managed roles are included.
22+
23+
Args:
24+
user: Django user model instance
25+
26+
Returns:
27+
QuerySet of role assignments annotated with:
28+
- aid: ansible_id from the resource registry
29+
- resource_name: name from the resource registry
30+
- rd_name: role definition name
31+
- content_type_id: Integer ID of the content type for the assigned object
32+
33+
The queryset is filtered to:
34+
- Object-scoped assignments only (content_type is not null)
35+
- JWT-managed roles only (as defined in settings)
36+
37+
Example usage:
38+
assignments = get_user_object_roles(user)
39+
for assignment in assignments:
40+
print(assignment.rd_name, assignment.aid, assignment.resource_name, assignment.content_type_id)
41+
"""
42+
# Create subqueries for resource data
43+
resource_cls = apps.get_model('dab_resource_registry', 'Resource')
44+
ansible_id_subquery = resource_cls.objects.filter(object_id=OuterRef('object_id'), content_type=OuterRef('content_type')).values('ansible_id')
45+
46+
resource_name_subquery = resource_cls.objects.filter(object_id=OuterRef('object_id'), content_type=OuterRef('content_type')).values('name')
47+
48+
return (
49+
user.role_assignments.filter(content_type__isnull=False)
50+
.annotate(aid=ansible_id_subquery, resource_name=resource_name_subquery, rd_name=F('role_definition__name'))
51+
.filter(rd_name__in=settings.ANSIBLE_BASE_JWT_MANAGED_ROLES)
52+
)
53+
54+
55+
def _resolve_team_organization_references(
56+
object_arrays: dict[str, list],
57+
org_ansible_id_to_index: dict[str, int],
58+
) -> None:
59+
"""Resolve team organization references by converting ansible_ids to array positions.
60+
61+
This method queries the team model to get organization mappings for teams,
62+
then updates team objects to reference their organizations by array position
63+
instead of ansible_id. If a team's organization is not already in the object
64+
arrays, it will be added.
65+
66+
Args:
67+
object_arrays: Dictionary with model_type -> list of objects (will be modified)
68+
org_ansible_id_to_index: Maps organization ansible_id -> array_position (will be modified)
69+
70+
The method modifies object_arrays and org_ansible_id_to_index in place:
71+
- Updates team objects' 'org' field from ansible_id to array position
72+
- Adds missing organizations to object_arrays['organization'] if needed
73+
- Updates org_ansible_id_to_index mappings for any added organizations
74+
"""
75+
# Extract team ansible_ids from the team objects
76+
team_ansible_ids = {team_data['ansible_id'] for team_data in object_arrays['team']}
77+
78+
if not team_ansible_ids:
79+
return
80+
81+
# Query team model to get team -> organization mappings with organization names
82+
team_cls = get_team_model()
83+
team_org_mapping = {}
84+
for team in team_cls.objects.filter(resource__ansible_id__in=team_ansible_ids).values(
85+
'resource__ansible_id', 'organization__resource__ansible_id', 'organization__name'
86+
):
87+
team_ansible_id = str(team['resource__ansible_id'])
88+
org_ansible_id = str(team['organization__resource__ansible_id'])
89+
org_name = team['organization__name']
90+
team_org_mapping[team_ansible_id] = {'ansible_id': org_ansible_id, 'name': org_name}
91+
92+
# Update team objects with organization references
93+
for team_data in object_arrays['team']:
94+
team_ansible_id = team_data['ansible_id']
95+
org_info = team_org_mapping.get(team_ansible_id)
96+
97+
if org_info:
98+
org_ansible_id = org_info['ansible_id']
99+
org_name = org_info['name']
100+
101+
# Ensure the organization is in our arrays
102+
if org_ansible_id not in org_ansible_id_to_index:
103+
# Add missing organization using data from the query
104+
if 'organization' not in object_arrays:
105+
object_arrays['organization'] = []
106+
org_index = len(object_arrays['organization'])
107+
org_ansible_id_to_index[org_ansible_id] = org_index
108+
org_data = {'ansible_id': org_ansible_id, 'name': org_name}
109+
object_arrays['organization'].append(org_data)
110+
111+
# Set the organization reference to the array position
112+
team_data['org'] = org_ansible_id_to_index[org_ansible_id]
113+
114+
115+
def _build_objects_and_roles(
116+
user: Model,
117+
) -> tuple[dict[str, list], dict[str, dict[str, Union[str, list[int]]]]]:
118+
"""Process user's object-scoped role assignments and return objects and roles data.
119+
120+
Args:
121+
user: User model instance
122+
123+
Returns:
124+
Tuple containing:
125+
- object_arrays: Dictionary with model_type -> list of objects
126+
- object_roles: Dictionary mapping role names to role data
127+
128+
Example:
129+
(
130+
{'organization': [{'ansible_id': 'uuid1', 'name': 'Org1'}], 'team': []},
131+
{'Organization Admin': {'content_type': 'organization', 'objects': [0]}}
132+
)
133+
"""
134+
# Initialize empty object arrays and roles with expected keys
135+
object_arrays = {'organization': [], 'team': []}
136+
object_roles = {}
137+
138+
# Internal tracking for ansible_id to array position mapping
139+
ansible_id_to_index = defaultdict(dict) # { <model_type>: {<ansible_id>: <array_position> } }
140+
141+
# Single loop: build object_arrays and object_roles
142+
for assignment in get_user_object_roles(user):
143+
role_name = assignment.rd_name
144+
ansible_id = str(assignment.aid)
145+
resource_name = str(assignment.resource_name)
146+
content_type_id = assignment.content_type_id
147+
model_type = DABContentType.objects.get_for_id(content_type_id).model
148+
149+
# Ensure the model_type exists in object_arrays (for non-standard types)
150+
if model_type not in object_arrays:
151+
object_arrays[model_type] = []
152+
153+
# If the ansible_id is not yet indexed
154+
if ansible_id not in ansible_id_to_index[model_type]:
155+
# Cache the array position (current len will be the next index when we append)
156+
ansible_id_to_index[model_type][ansible_id] = len(object_arrays[model_type])
157+
# Add the object to the array
158+
object_data = {'ansible_id': ansible_id, 'name': resource_name}
159+
object_arrays[model_type].append(object_data)
160+
161+
# Get the array position from the cache
162+
array_position = ansible_id_to_index[model_type][ansible_id]
163+
164+
# If the role is not in object_roles, initialize it
165+
if role_name not in object_roles:
166+
object_roles[role_name] = {'content_type': model_type, 'objects': []}
167+
168+
# Add the array position to the role
169+
object_roles[role_name]['objects'].append(array_position)
170+
171+
# Resolve team organization references
172+
_resolve_team_organization_references(object_arrays, ansible_id_to_index['organization'])
173+
174+
return object_arrays, object_roles
175+
176+
177+
def _get_user_global_roles(user: Model) -> list[str]:
178+
"""Get user's global role assignments.
179+
180+
Args:
181+
user: User model instance
182+
183+
Returns:
184+
List of global role names assigned to the user
185+
186+
Example:
187+
['Platform Auditor', 'System Administrator']
188+
"""
189+
global_roles_query = RoleDefinition.objects.filter(content_type=None, user_assignments__user=user.pk, name__in=settings.ANSIBLE_BASE_JWT_MANAGED_ROLES)
190+
191+
return [role_definition.name for role_definition in global_roles_query]
192+
193+
194+
def get_user_claims(user: Model) -> dict[str, Union[list[str], dict[str, Union[str, list[dict[str, str]]]]]]:
195+
"""Generate comprehensive claims data for a user including roles and object access.
196+
197+
This function builds a complete picture of a user's permissions by gathering:
198+
- Global roles (system-wide permissions)
199+
- Object roles (permissions on specific resources)
200+
- Object metadata (organizations and teams the user has access to)
201+
202+
Args:
203+
user: Django user model instance
204+
205+
Returns:
206+
Dictionary containing:
207+
- objects: Nested dict with arrays of organization/team objects user has access to
208+
- object_roles: Dict mapping role names to content types and object indexes
209+
- global_roles: List of global role names assigned to the user
210+
211+
Example:
212+
{
213+
'objects': {
214+
'organization': [{'ansible_id': 'uuid1', 'name': 'Org1'}],
215+
'team': [{'ansible_id': 'uuid2', 'name': 'Team1', 'org': 0}]
216+
},
217+
'object_roles': {
218+
'Organization Admin': {'content_type': 'organization', 'objects': [0]}
219+
},
220+
'global_roles': ['Platform Auditor']
221+
}
222+
"""
223+
# Warm the DABContentType cache for efficient lookups
224+
DABContentType.objects.warm_cache()
225+
226+
# Build object arrays and roles from user's assignments
227+
object_arrays, object_roles = _build_objects_and_roles(user)
228+
229+
# Get global roles
230+
global_roles = _get_user_global_roles(user)
231+
232+
# Build final claims structure
233+
return {'objects': object_arrays, 'object_roles': object_roles, 'global_roles': global_roles}
234+
235+
236+
def get_user_claims_hashable_form(claims: dict) -> dict[str, Union[list[str], dict[str, list[str]]]]:
237+
"""Convert user claims to hashable form suitable for generating deterministic hashes.
238+
239+
Args:
240+
claims: Claims dictionary from get_user_claims()
241+
242+
The hashable form:
243+
- Removes the 'objects' section entirely
244+
- Converts object role references from array indexes to ansible_id values
245+
- Sorts all collections for deterministic ordering
246+
- Uses ansible_id for object references (or id if no ansible_id, for future use)
247+
248+
Returns:
249+
{
250+
'global_roles': ['Platform Auditor', 'System Admin'], # sorted
251+
'object_roles': {
252+
'Organization Admin': ['uuid1', 'uuid2'], # sorted ansible_ids
253+
'Team Member': ['uuid3', 'uuid4'] # sorted ansible_ids
254+
}
255+
}
256+
"""
257+
258+
hashable_claims = {'global_roles': sorted(claims['global_roles']), 'object_roles': {}}
259+
260+
# Convert object_roles from indexes to ansible_ids
261+
for role_name, role_data in claims['object_roles'].items():
262+
content_type = role_data['content_type']
263+
object_indexes = role_data['objects']
264+
265+
# Get the objects array for this content type
266+
objects_array = claims['objects'].get(content_type, [])
267+
268+
# Convert indexes to ansible_ids
269+
ansible_ids = []
270+
for index in object_indexes:
271+
if index < len(objects_array):
272+
obj_data = objects_array[index]
273+
# Use ansible_id if available, otherwise fall back to id (for future use)
274+
ansible_id = obj_data.get('ansible_id') or str(obj_data.get('id', ''))
275+
if ansible_id:
276+
ansible_ids.append(ansible_id)
277+
278+
# Sort ansible_ids for deterministic ordering
279+
hashable_claims['object_roles'][role_name] = sorted(ansible_ids)
280+
281+
return hashable_claims
282+
283+
284+
def get_claims_hash(hashable_claims: dict) -> str:
285+
"""Generate a deterministic SHA-256 hash from hashable claims data.
286+
287+
Args:
288+
hashable_claims: Output from get_user_claims_hashable_form()
289+
290+
Returns:
291+
64-character hex string representing the SHA-256 hash of the claims
292+
293+
The hash is generated by:
294+
1. Serializing the hashable claims to JSON with sorted keys
295+
2. Encoding to UTF-8 bytes
296+
3. Computing SHA-256 hash
297+
4. Returning as hexadecimal string
298+
"""
299+
# Serialize to JSON with sorted keys for deterministic output
300+
json_str = json.dumps(hashable_claims, sort_keys=True, separators=(',', ':'))
301+
302+
# Encode to bytes and compute SHA-256 hash
303+
json_bytes = json_str.encode('utf-8')
304+
hash_digest = hashlib.sha256(json_bytes).hexdigest()
305+
306+
return hash_digest

0 commit comments

Comments
 (0)