Skip to content

Commit 215a77f

Browse files
authored
Merge pull request #776 from MetaCell/feature/CH-152
Generic user attributes API
2 parents 2aa5bf9 + f580dec commit 215a77f

9 files changed

Lines changed: 377 additions & 333 deletions

File tree

deployment-configuration/codefresh-template-test.yaml

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,4 @@ steps:
132132
when:
133133
condition:
134134
all:
135-
error: '"${{FAILED}}" == "failed"'
136-
delete_deployment:
137-
title: "Delete deployment"
138-
description: The deployment is deleted at the end of the pipeline
139-
image: codefresh/kubectl
140-
stage: qa
141-
commands:
142-
- kubectl config use-context ${{CLUSTER_NAME}}
143-
- kubectl delete ns test-${{NAMESPACE_BASENAME}}
144-
when:
145-
condition:
146-
all:
147-
delete: ${{DELETE_ON_SUCCESS}} == "true"
135+
error: '"${{FAILED}}" == "failed"'

deployment/codefresh-test.yaml

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ steps:
522522
approval:
523523
type: pending-approval
524524
stage: qa
525-
title: Approve anyway and delete deployment
525+
title: Approve anyway
526526
description: The pipeline will fail after ${{WAIT_ON_FAIL}} minutes
527527
timeout:
528528
timeUnit: minutes
@@ -531,16 +531,4 @@ steps:
531531
when:
532532
condition:
533533
all:
534-
error: '"${{FAILED}}" == "failed"'
535-
delete_deployment:
536-
title: Delete deployment
537-
description: The deployment is deleted at the end of the pipeline
538-
image: codefresh/kubectl
539-
stage: qa
540-
commands:
541-
- kubectl config use-context ${{CLUSTER_NAME}}
542-
- kubectl delete ns test-${{NAMESPACE_BASENAME}}
543-
when:
544-
condition:
545-
all:
546-
delete: '${{DELETE_ON_SUCCESS}} == "true"'
534+
error: '"${{FAILED}}" == "failed"'

docs/accounts.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,35 @@ harness:
178178

179179
The above configuration will create 3 client roles under the "myapp" client and 2 users.
180180

181-
---
181+
182182
**NOTE**
183-
Users and client roles are defined as a one-off initialization: they
183+
> Users and client roles are defined as a one-off initialization: they
184184
can be configured only on a new deployment and cannot be updated.
185-
---
185+
186+
187+
### Retrieve user attributes Python API
188+
189+
The auth API provides a way to get user attributes merged with groups attributes recursively.
190+
This allows us to define an attribute that is common for different users per group.
191+
A common use case is the definition of usage quotas, for which cloudharness provides a
192+
high level API.
193+
194+
Example retrieve attributes:
195+
196+
```Python
197+
from clouharness.auth.user_attributes import get_user_attributes
198+
199+
attributes = get_user_attributes(kc_user_id_or_name)
200+
```
201+
202+
The API provides parameters for filtering and provide a set of default values.
203+
204+
The user quotas API also assumes that a set of default values can be specified at application level
205+
on `harness/quotas` and all quotas attributes begin with the `quota-` prefix.
206+
207+
Example:
208+
209+
```Python
210+
from clouharness.auth.quotas import get_user_quotas
211+
quotas = get_user_quotas(kc_user_id_or_name) # retrieves default quotas values from the current application
212+
```

libraries/cloudharness-common/cloudharness/auth/quota.py

Lines changed: 8 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -5,106 +5,14 @@
55
from cloudharness_model.models import ApplicationConfig
66
from cloudharness import log
77

8+
from .user_attributes import UserNotFound, _filter_attrs, _construct_attribute_tree, _compute_attributes_from_tree, get_user_attributes
89
# quota tree node to hold the tree quota attributes
910

1011

11-
class QuotaNode:
12-
def __init__(self, name, attrs):
13-
self.attrs = attrs
14-
self.name = name
15-
self.children = []
16-
17-
def addChild(self, child):
18-
self.children.append(child)
19-
20-
21-
def _filter_quota_attrs(attrs, valid_keys_map):
22-
# only use the attributes defined by the valid keys map
23-
valid_attrs = {}
24-
if attrs is None:
25-
return valid_attrs
26-
for key in attrs:
27-
if key in valid_keys_map:
28-
# map to value
29-
valid_attrs.update({key: attrs[key][0]})
30-
return valid_attrs
31-
32-
3312
def get_group_quotas(group, application_config: ApplicationConfig):
3413
base_quotas = application_config.get("harness", {}).get("quotas", {})
3514
valid_keys_map = {key for key in base_quotas}
36-
return _compute_quotas_from_tree(_construct_quota_tree([group], valid_keys_map))
37-
38-
39-
def _construct_quota_tree(groups, valid_keys_map) -> QuotaNode:
40-
root = QuotaNode("root", {})
41-
for group in groups:
42-
r = root
43-
paths = group["path"].split("/")[1:]
44-
# loop through all segements except the last segment
45-
# the last segment is the one we want to add the attributes to
46-
for segment in paths[0: len(paths) - 1]:
47-
for child in r.children:
48-
if child.name == segment:
49-
r = child
50-
break
51-
else:
52-
# no child found, add it with the segment name of the path
53-
n = QuotaNode(segment, {})
54-
r.addChild(n)
55-
r = n
56-
# add the child with it's attributes and last segment name
57-
n = QuotaNode(
58-
paths[len(paths) - 1],
59-
_filter_quota_attrs(group["attributes"], valid_keys_map)
60-
)
61-
r.addChild(n)
62-
return root
63-
64-
65-
def _compute_quotas_from_tree(node: QuotaNode):
66-
"""Recursively traverse the tree and find the quota per level
67-
the lower leafs overrule parent leafs values
68-
69-
Args:
70-
node (QuotaNode): the quota tree of QuotaNodes of the user for the given application
71-
72-
Returns:
73-
dict: key/value pairs of the quotas
74-
75-
Example:
76-
{'quota-ws-maxcpu': 1000, 'quota-ws-open': 10, 'quota-ws-max': 8}
77-
78-
Algorithm explanation:
79-
/Base {'quota-ws-max': 12345, 'quota-ws-maxcpu': 50, 'quota-ws-open': 1}\n
80-
/Base/Base 1/Base 1 1 {'quota-ws-maxcpu': 2, 'quota-ws-open': 10}\n
81-
/Base/Base 2 {'quota-ws-max': 8, 'quota-ws-maxcpu': 250}\n
82-
/Low CPU {'quota-ws-max': 3, 'quota-ws-maxcpu': 1000, 'quota-ws-open': 1}\n
83-
84-
result: {'quota-ws-maxcpu': 1000, 'quota-ws-open': 10, 'quota-ws-max': 8}\n
85-
quota-ws-maxcpu from path "/Low CPU"\n
86-
--> overrules paths "/Base/Base 1/Base 1 1" and "/Base/Base 2" (higher value)\n
87-
--> /Base quota-ws-max is not used because this one is not the lowest
88-
leaf with this attribute (Base 1 1 and Base 2 are "lower")\n
89-
quota-ws-open from path "/Base/Base 1/Base 1 1"\n
90-
quota-ws-max from path "/Base/Base 2"\n
91-
"""
92-
new_attrs = {}
93-
for child in node.children:
94-
child_attrs = _compute_quotas_from_tree(child)
95-
for key in child_attrs:
96-
try:
97-
# we expect all quota values to be numbers: the unit is implicit and
98-
# defined at usage time
99-
child_val = attribute_to_quota(child_attrs[key])
100-
except:
101-
# value not a float, skip
102-
continue
103-
if not key in new_attrs or new_attrs[key] < child_val:
104-
new_attrs.update({key: child_val})
105-
for key in new_attrs:
106-
node.attrs.update({key: new_attrs[key]})
107-
return node.attrs
15+
return _compute_attributes_from_tree(_construct_attribute_tree([group], valid_keys_map))
10816

10917

11018
def attribute_to_quota(attr_value: str):
@@ -126,28 +34,13 @@ def get_user_quotas(application_config: ApplicationConfig = None, user_id: str =
12634
"""
12735
if not application_config:
12836
application_config = get_current_configuration()
129-
13037
base_quotas = application_config.get("harness", {}).get("quotas", {})
131-
try:
132-
auth_client = AuthClient()
133-
if not user_id:
134-
user_id = auth_client.get_current_user()["id"]
135-
user = auth_client.get_user(user_id, with_details=True)
136-
except KeycloakError as e:
137-
log.warning("Quotas not available: error retrieving user: %s", user_id)
138-
return base_quotas
13938

14039
valid_keys_map = {key for key in base_quotas}
14140

142-
group_quotas = _compute_quotas_from_tree(
143-
_construct_quota_tree(
144-
user["userGroups"],
145-
valid_keys_map))
146-
user_quotas = _filter_quota_attrs(user["attributes"], valid_keys_map)
147-
for key in group_quotas:
148-
if key not in user_quotas:
149-
user_quotas.update({key: group_quotas[key]})
150-
for key in base_quotas:
151-
if key not in user_quotas:
152-
user_quotas.update({key: attribute_to_quota(base_quotas[key])})
153-
return user_quotas
41+
try:
42+
return get_user_attributes(user_id, valid_keys=valid_keys_map, default_attributes=base_quotas)
43+
44+
except UserNotFound as e:
45+
log.warning("Quotas not available: error retrieving user: %s", user_id)
46+
return base_quotas
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import re
2+
from keycloak import KeycloakError
3+
from .keycloak import AuthClient
4+
from cloudharness.applications import get_current_configuration
5+
from cloudharness_model.models import ApplicationConfig
6+
from cloudharness import log
7+
8+
9+
class KCAttributeNode:
10+
def __init__(self, name, attrs):
11+
self.attrs = attrs
12+
self.name = name
13+
self.children = []
14+
15+
def addChild(self, child):
16+
self.children.append(child)
17+
18+
19+
def _filter_attrs(attrs, valid_keys):
20+
# only use the attributes defined by the valid keys map
21+
valid_attrs = {}
22+
if attrs is None:
23+
return valid_attrs
24+
for key in attrs:
25+
if key in valid_keys:
26+
# map to value
27+
valid_attrs.update({key: attrs[key][0]})
28+
return valid_attrs
29+
30+
31+
def _construct_attribute_tree(groups, valid_keys) -> KCAttributeNode:
32+
"""Construct a tree of attributes from the user groups"""
33+
root = KCAttributeNode("root", {})
34+
for group in groups:
35+
r = root
36+
paths = group["path"].split("/")[1:]
37+
# loop through all segements except the last segment
38+
# the last segment is the one we want to add the attributes to
39+
for segment in paths[0: len(paths) - 1]:
40+
for child in r.children:
41+
if child.name == segment:
42+
r = child
43+
break
44+
else:
45+
# no child found, add it with the segment name of the path
46+
n = KCAttributeNode(segment, {})
47+
r.addChild(n)
48+
r = n
49+
# add the child with it's attributes and last segment name
50+
n = KCAttributeNode(
51+
paths[len(paths) - 1],
52+
_filter_attrs(group["attributes"], valid_keys)
53+
)
54+
r.addChild(n)
55+
return root
56+
57+
58+
class UserNotFound(Exception):
59+
pass
60+
61+
62+
def _compute_attributes_from_tree(node: KCAttributeNode, transform_value_fn=lambda x: x):
63+
"""Recursively traverse the tree and find the attributes per level
64+
the lower leafs overrule parent leafs values
65+
66+
Args:
67+
node (QuotaNode): the quota tree of QuotaNodes of the user for the given application
68+
transform_value_fn (function): function to transform the value of the attribute
69+
70+
Returns:
71+
dict: key/value pairs of the quotas
72+
73+
Example:
74+
{'quota-ws-maxcpu': 1000, 'quota-ws-open': 10, 'quota-ws-max': 8}
75+
76+
Algorithm explanation:
77+
/Base {'quota-ws-max': 12345, 'quota-ws-maxcpu': 50, 'quota-ws-open': 1}\n
78+
/Base/Base 1/Base 1 1 {'quota-ws-maxcpu': 2, 'quota-ws-open': 10}\n
79+
/Base/Base 2 {'quota-ws-max': 8, 'quota-ws-maxcpu': 250}\n
80+
/Low CPU {'quota-ws-max': 3, 'quota-ws-maxcpu': 1000, 'quota-ws-open': 1}\n
81+
82+
result: {'quota-ws-maxcpu': 1000, 'quota-ws-open': 10, 'quota-ws-max': 8}\n
83+
quota-ws-maxcpu from path "/Low CPU"\n
84+
--> overrules paths "/Base/Base 1/Base 1 1" and "/Base/Base 2" (higher value)\n
85+
--> /Base quota-ws-max is not used because this one is not the lowest
86+
leaf with this attribute (Base 1 1 and Base 2 are "lower")\n
87+
quota-ws-open from path "/Base/Base 1/Base 1 1"\n
88+
quota-ws-max from path "/Base/Base 2"\n
89+
"""
90+
new_attrs = {}
91+
for child in node.children:
92+
child_attrs = _compute_attributes_from_tree(child)
93+
for key in child_attrs:
94+
try:
95+
child_val = transform_value_fn(child_attrs[key])
96+
except:
97+
# value not a float, skip
98+
continue
99+
if not key in new_attrs or new_attrs[key] < child_val:
100+
new_attrs.update({key: child_val})
101+
for key in new_attrs:
102+
node.attrs.update({key: new_attrs[key]})
103+
return node.attrs
104+
105+
106+
def get_user_attributes(user_id: str = None, valid_keys={}, default_attributes={}, transform_value_fn=lambda x: x) -> dict:
107+
"""Get the user attributes from Keycloak recursively from the user attributes and groups
108+
109+
Args:
110+
user_id (str): the Keycloak user id or username to get the quotas for
111+
valid_keys (iterable): the valid keys to use for the attributes
112+
default_attributes (dict): the default attributes to use if the user does not have the attribute
113+
114+
Returns:
115+
dict: key/value pairs of the user attributes
116+
117+
Example:
118+
{'quota-ws-maxcpu': 1000, 'quota-ws-open': 10, 'quota-ws-max': 8}
119+
"""
120+
121+
try:
122+
auth_client = AuthClient()
123+
if not user_id:
124+
user_id = auth_client.get_current_user()["id"]
125+
user = auth_client.get_user(user_id, with_details=True)
126+
except KeycloakError as e:
127+
log.warning("Quotas not available: error retrieving user: %s", user_id)
128+
raise UserNotFound("User not found") from e
129+
130+
group_quotas = _compute_attributes_from_tree(
131+
_construct_attribute_tree(
132+
user["userGroups"],
133+
valid_keys), transform_value_fn)
134+
user_attrs = _filter_attrs(user["attributes"], valid_keys)
135+
for key in group_quotas:
136+
if key not in user_attrs:
137+
user_attrs.update({key: group_quotas[key]})
138+
for key in default_attributes:
139+
if key not in user_attrs:
140+
user_attrs.update({key: transform_value_fn(default_attributes[key])})
141+
return user_attrs

0 commit comments

Comments
 (0)