From d8562b8d475815e547d454074a2a432b2ac9abea Mon Sep 17 00:00:00 2001 From: Etienne Jodry Date: Thu, 24 Oct 2024 16:12:10 +0200 Subject: [PATCH 1/2] Fix statement building and updating nested edge cases, make release of new version adopt the nested lists of previous version, some unit tests, coming up: doc --- src/biodm/component.py | 1 - .../controllers/resourcecontroller.py | 45 ++++--- src/biodm/components/services/dbservice.py | 44 +++++-- src/biodm/utils/security.py | 5 +- src/biodm/utils/sqla.py | 16 ++- src/tests/integration/kc/test_permissions.py | 114 ++++++++++++++---- src/tests/unit/conftest.py | 45 ++++++- src/tests/unit/test_versioning.py | 87 ++++++++++++- 8 files changed, 286 insertions(+), 71 deletions(-) diff --git a/src/biodm/component.py b/src/biodm/component.py index 2b61cad..dad0492 100644 --- a/src/biodm/component.py +++ b/src/biodm/component.py @@ -54,7 +54,6 @@ async def write( async def release( self, pk_val: List[Any], - fields: List[str], update: Dict[str, Any], session: AsyncSession, user_info: UserInfo | None = None, diff --git a/src/biodm/components/controllers/resourcecontroller.py b/src/biodm/components/controllers/resourcecontroller.py index a5e7093..80e967e 100644 --- a/src/biodm/components/controllers/resourcecontroller.py +++ b/src/biodm/components/controllers/resourcecontroller.py @@ -261,8 +261,7 @@ async def _extract_body(self, request: Request) -> bytes: def _extract_fields( self, query_params: Dict[str, Any], - user_info: UserInfo, - no_depth: bool = False, + user_info: UserInfo ) -> Set[str]: """Extracts fields from request query parameters. Defaults to ``self.schema.dump_fields.keys()``. @@ -285,7 +284,6 @@ def _extract_fields( else: # Default case, gracefully populate allowed fields. fields = [ k for k,v in self.schema.dump_fields.items() - if not no_depth or not (hasattr(v, 'nested') or hasattr(v, 'inner')) ] fields = self.svc.takeout_unallowed_nested(fields, user_info=user_info) return fields @@ -447,16 +445,21 @@ async def update(self, request: Request) -> Response: # Plug in pk into the dict. validated_data.update(dict(zip(self.pk, pk_val))) # type: ignore [assignment] - - return json_response( - data=await self.svc.write( - data=validated_data, - stmt_only=False, - user_info=request.state.user_info, - serializer=partial(self.serialize, many=isinstance(validated_data, list)), - ), - status_code=201, - ) + try: + return json_response( + data=await self.svc.write( + data=validated_data, + stmt_only=False, + user_info=request.state.user_info, + serializer=partial(self.serialize, many=isinstance(validated_data, list)), + ), + status_code=201, + ) + except IntegrityError as ie: + if 'UNIQUE' in ie.args[0] and 'version' in ie.args[0]: + raise UpdateVersionedError( + "Attempt at updating versioned resources via POST detected" + ) async def delete(self, request: Request) -> Response: """Delete resource. @@ -558,18 +561,14 @@ async def release(self, request: Request) -> Response: fields = self._extract_fields( dict(request.query_params), - user_info=request.state.user_info, - no_depth=True + user_info=request.state.user_info ) - # Note: serialization is delayed. Hence the no_depth. return json_response( - self.serialize( - await self.svc.release( - pk_val=self._extract_pk_val(request), - fields=fields, - update=validated_data, - user_info=request.state.user_info, - ), many=False, only=fields + await self.svc.release( + pk_val=self._extract_pk_val(request), + update=validated_data, + user_info=request.state.user_info, + serializer=partial(self.serialize, many=False, only=fields), ), status_code=200 ) diff --git a/src/biodm/components/services/dbservice.py b/src/biodm/components/services/dbservice.py index 6f3029b..aaf01cc 100644 --- a/src/biodm/components/services/dbservice.py +++ b/src/biodm/components/services/dbservice.py @@ -20,8 +20,8 @@ from biodm.managers import DatabaseManager from biodm.tables import ListGroup, Group from biodm.tables.asso import asso_list_group -from biodm.utils.security import UserInfo, Permission, PermissionLookupTables -from biodm.utils.sqla import CompositeInsert, UpsertStmt, stmt_to_dict, UpsertStmtValuesHolder +from biodm.utils.security import UserInfo, PermissionLookupTables +from biodm.utils.sqla import CompositeInsert, UpsertStmt, UpsertStmtValuesHolder from biodm.utils.utils import unevalled_all, unevalled_or, to_it, partition @@ -519,7 +519,9 @@ async def write( ] if len(stmts) == 1: - return stmts[0] if stmt_only else await self._insert(stmts[0], user_info=user_info, **kwargs) + return stmts[0] if stmt_only else await self._insert( + stmts[0], user_info=user_info, **kwargs + ) return stmts if stmt_only else await self._insert_list(stmts, user_info=user_info, **kwargs) @DatabaseManager.in_session @@ -797,7 +799,6 @@ async def delete(self, pk_val, user_info: UserInfo | None = None, **kwargs) -> N async def release( self, pk_val: List[Any], - fields: List[str], update: Dict[str, Any], session: AsyncSession, user_info: UserInfo | None = None, @@ -805,19 +806,29 @@ async def release( await self._check_permissions( "write", user_info, dict(zip(self.pk, pk_val)), session=session ) + from copy import deepcopy - item = await self.read(pk_val, fields, session=session) - # Put item in a `flexible` state where we may edit pk. - make_transient(item) - item.version += 1 + # Get item with all columns - covers x-to-one relationships. + old_item = await self.read(pk_val, self.table.__table__.columns.keys(), session=session) + + # Copy and put in a `flexible` state where we may edit pk. + new_item = deepcopy(old_item) + make_transient(new_item) # Apply update. + new_item.version += 1 for key, val in update.items(): - setattr(item, key, val) + setattr(new_item, key, val) + + # covers x-to-many relationships + x_to_many = [key for key, rel in self.table.relationships.items() if rel.uselist] + await session.refresh(old_item, x_to_many) + for key in x_to_many: + setattr(new_item, key, getattr(old_item, key)) # new pk -> new row. - session.add(item) - return item + session.add(new_item) + return new_item class CompositeEntityService(UnaryEntityService): @@ -925,8 +936,15 @@ def patch(ins, mapping): # Refresh objects that were present so item comes back with updated values. for u in updated: await session.refresh(u) + # Add new stuff - attr.extend(delay) + if isinstance(attr, list): + attr.extend(delay) + elif isinstance(attr, set): + for d in delay: + attr.add(d) + else: # Should not happen, but will trigger a warning in case. + raise NotImplementedError else: setattr(item, key, await svc._insert(delay, **kwargs)) return item @@ -976,7 +994,7 @@ async def _parse_composite( ( self._inst_relationships.keys() & data.keys() ) - | set(self.permission_relationships.keys()) # TODO: factor in above here. + | set(self.permission_relationships.keys()) ): svc = self._svc_from_rel_name(key) sub = data.pop(key, {}) # {} default value -> only happens for empty permissions. diff --git a/src/biodm/utils/security.py b/src/biodm/utils/security.py index 9dc9134..8a2f76c 100644 --- a/src/biodm/utils/security.py +++ b/src/biodm/utils/security.py @@ -286,7 +286,10 @@ class ASSO_PERM_{TABLE}_{FIELD}(Base): { f"id_{verb}": c, f"{verb}": relationship( - "ListGroup", cascade="save-update, merge, delete", foreign_keys=[c] + "ListGroup", + cascade="save-update, merge, delete, delete-orphan", + foreign_keys=[c], + single_parent=True ) } ) diff --git a/src/biodm/utils/sqla.py b/src/biodm/utils/sqla.py index f1bfe73..0f5fff2 100644 --- a/src/biodm/utils/sqla.py +++ b/src/biodm/utils/sqla.py @@ -37,7 +37,7 @@ class UpsertStmtValuesHolder(dict): def to_stmt(self, svc: 'DatabaseService') -> Insert | Update | Select: """Generates an upsert (Insert + .on_conflict_do_x) depending on data population. - OR an explicit Update statement for partial data with full primary key + OR an explicit Update statement for full primary key and data OR an explicit Select statement for full primary key and no data the above edge cases do not necessarily always return a value, hence we handle them that way to guarantee consistency. @@ -53,13 +53,14 @@ def to_stmt(self, svc: 'DatabaseService') -> Insert | Update | Select: """ pk = svc.table.pk missing_data = svc.table.required - self.keys() + pk_present = all(k in self.keys() for k in pk) set_ = { key: self[key] for key in self.keys() - pk } - if missing_data and all(k in self.keys() for k in pk): + if missing_data and pk_present: if set_: # Missing data & pk present & values -> UPDATE. stmt = ( update(svc.table) @@ -68,21 +69,26 @@ def to_stmt(self, svc: 'DatabaseService') -> Insert | Update | Select: ) else: # ... no values -> SELECT. stmt = select(svc.table) - # Full pk is present so we can generate this where cond. stmt = stmt.where(svc.gen_cond([self.get(k) for k in pk])) return stmt # Regular case - stmt = insert(svc.table)# or _backend_specific_insert(svc.table) + stmt = insert(svc.table) stmt = stmt.values(**self) + stmt = stmt.returning(svc.table) if not svc.table.is_versioned: if set_: # upsert stmt = stmt.on_conflict_do_update(index_elements=pk, set_=set_) else: # insert with default values stmt = stmt.on_conflict_do_nothing(index_elements=pk) + if pk_present: # Ensure that on_conflict_do_nothing will return a result. + # https://stackoverflow.com/a/62205017/6847689 + # https://github.com/sqlalchemy/sqlalchemy/discussions/10605 + one = select(stmt.cte()) + two = select(svc.table).where(svc.gen_cond([self.get(k) for k in pk])) + stmt = select(svc.table).from_statement(one.union(two)) # Else (implicit): on_conflict_do_error -> catched by Controller. - stmt = stmt.returning(svc.table) return stmt diff --git a/src/tests/integration/kc/test_permissions.py b/src/tests/integration/kc/test_permissions.py index 5302b48..d1db0ae 100644 --- a/src/tests/integration/kc/test_permissions.py +++ b/src/tests/integration/kc/test_permissions.py @@ -1,6 +1,7 @@ import json import pytest import requests +from typing import Any, Dict from uuid import uuid4 session_id = uuid4() @@ -9,56 +10,60 @@ token_user2: str = "" token_user2_child: str = "" -user1 = { + +project_2_id: int +project_2_read_id: int + + +user1: Dict[str, str] = { "username":f"u_{session_id}_1", "password": "1234", } -user2 = { + +user2: Dict[str, str] = { "username": f"u_{session_id}_2", "password": "1234", } -user2_child = { + +user2_child: Dict[str, str] = { "username": f"u_{session_id}_2_child", "password": "1234" } -# user2_grandchild = { -# "username": f"u_{session_id}_2_grandchild", -# } -user3 = { +user3: Dict[str, str] = { "username": f"u_{session_id}_3", "password": "1234", } -group1 = { + +group1: Dict[str, str] = { "path":f"g_{session_id}_1", "users":[user1], } -group2 = { + +group2: Dict[str, str] = { "path":f"g_{session_id}_2", "users":[user2], } -group2_child = { + +group2_child: Dict[str, str] = { "path": group2['path'] + "__child", "users": [user2_child], } -# group2_grandchild = { -# "path": group2_child['path'] + "__grandchild", -# "users":[user2_grandchild], -# } -group3 = { +group3: Dict[str, str] = { "path":f"g_{session_id}_3", "users":[user3], } -project1 = { + +project1: Dict[str, Any] = { "name": f"pr_{session_id}_1", "perm_datasets": { "read": { @@ -76,7 +81,8 @@ } } -project2 = { + +project2: Dict[str, Any] = { "name": f"pr_{session_id}_2", "perm_datasets": { "read": { @@ -93,7 +99,7 @@ } -dataset1 = { +dataset1: Dict[str, Any] = { "name": "ds_test", "project_id": "1", "contact": { @@ -114,7 +120,7 @@ } -dataset2 = { +dataset2: Dict[str, Any] = { "name": "ds_test_parent", "project_id": "2", "contact": { @@ -123,7 +129,7 @@ } -public_project = { +public_project: Dict[str, Any] = { "name": f"pr_{session_id}_public", "datasets": [ { @@ -131,13 +137,14 @@ "contact": { "username": user1['username'] }, + "tags": [{"name": "bip"},{"name": "bap"}] }, ] } def test_create_data_and_login(srv_endpoint, utils): - global token_user1, token_user2, token_user2_child + global token_user1, token_user2, token_user2_child, project_2_id, project_2_read_id groups = requests.post(f"{srv_endpoint}/groups", data=utils.json_bytes( [ @@ -159,6 +166,12 @@ def test_create_data_and_login(srv_endpoint, utils): srv_endpoint, user2_child['username'], user2_child['password']) + json_pro = json.loads(projects.text) + assert len(json_pro) == 2 + project_2_id = json_pro[-1]['id'] + project_2_read_id = json_pro[-1]['perm_datasets']['read']['id'] + + @pytest.mark.dependency(name="test_create_data_and_login") def test_create_dataset(srv_endpoint, utils): """User 1, write perm on Project 1.""" @@ -217,10 +230,14 @@ def test_read_dataset_no_read_perm(srv_endpoint): assert json_response2 == [] +@pytest.mark.dependency(name="test_create_data_and_login") def test_create_public_data(srv_endpoint, utils): + headers1 = {'Authorization': f'Bearer {token_user1}'} + response = requests.post( f'{srv_endpoint}/projects', data=utils.json_bytes(public_project), + headers=headers1 ) assert response.status_code == 201 @@ -260,3 +277,58 @@ def test_create_from_child_group(srv_endpoint, utils): ) assert response.status_code == 201 + +@pytest.mark.dependency(name="test_create_data_and_login") +def test_add_to_project_permission(srv_endpoint, utils): + project_update = { + "perm_datasets": { + "read": { + "id": project_2_read_id, + "groups": [ + {"path": group3['path']}, + ] + } + } + } + + # Will now contain both group2 from creation and group3 from update + response = requests.put( + f'{srv_endpoint}/projects/{project_2_id}', + data=utils.json_bytes(project_update) + ) + + assert response.status_code == 201 + json_response = json.loads(response.text) + assert json_response['perm_datasets']['read']['id'] == project_2_read_id + + groups_oracle = [ + {"path": group2['path']}, + {"path": group3['path']} + ] + assert json_response['perm_datasets']['read']['groups'] == groups_oracle + + +@pytest.mark.dependency(name="test_add_to_project_permission") +def test_change_project_permission(srv_endpoint, utils): + project_update = { + "perm_datasets": { + "read": { + "groups": [ + {"path": group1['path']}, + ] + } + } + } + + response = requests.put( + f'{srv_endpoint}/projects/{project_2_id}', + data=utils.json_bytes(project_update) + ) + + assert response.status_code == 201 + json_response = json.loads(response.text) + assert json_response['perm_datasets']['read']['id'] != project_2_read_id + assert ( + json_response['perm_datasets']['read']['groups'] == + project_update["perm_datasets"]["read"]["groups"] + ) diff --git a/src/tests/unit/conftest.py b/src/tests/unit/conftest.py index cfb25e3..cd3f3d8 100644 --- a/src/tests/unit/conftest.py +++ b/src/tests/unit/conftest.py @@ -16,13 +16,24 @@ "ASSO_A_B", Base.metadata, sa.Column("id_a", sa.ForeignKey("A.id"), primary_key=True), - sa.Column("id_b", sa.Integer(), primary_key=True), - sa.Column("version_b", sa.Integer(), primary_key=True), + sa.Column("id_b", sa.Integer(), primary_key=True), + sa.Column("version_b", sa.Integer(), primary_key=True), sa.ForeignKeyConstraint( ['id_b', 'version_b'], ['B.id', 'B.version'] ) ) +asso_c_d = sa.Table( + "ASSO_C_D", + Base.metadata, + sa.Column("c_id", sa.ForeignKey("C.id"), primary_key=True), + sa.Column("d_id", sa.Integer(), primary_key=True), + sa.Column("d_version", sa.Integer(), primary_key=True), + sa.ForeignKeyConstraint( + ['d_id', 'd_version'], ['D.id', 'D.version'] + ) +) + class A(Base): id = sa.Column(sa.Integer, primary_key=True) @@ -35,7 +46,7 @@ class A(Base): class B(Versioned, Base): - id = sa.Column(sa.Integer, primary_key=True, autoincrement=False) + id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, nullable=False) @@ -44,6 +55,14 @@ class C(Base): data = sa.Column(sa.String, nullable=False) +class D(Versioned, Base): + id = sa.Column(sa.Integer, primary_key=True) + + info = sa.Column(sa.String, nullable=False) + + cs: Mapped[List["C"]] = relationship(secondary=asso_c_d, uselist=True, lazy="select") + + # Schemas class ASchema(ma.Schema): id = ma.fields.Integer() @@ -59,16 +78,25 @@ class BSchema(ma.Schema): id = ma.fields.Integer() version = ma.fields.Integer() - name = ma.fields.String(required=True) + name = ma.fields.String() class CSchema(ma.Schema): id = ma.fields.Integer() - data = ma.fields.String(required=True) + data = ma.fields.String() ca = ma.fields.Nested("ASchema") +class DSchema(ma.Schema): + id = ma.fields.Integer() + version = ma.fields.Integer() + + info = ma.fields.String() + + cs = ma.fields.List(ma.fields.Nested("CSchema")) + + # Api componenents. class AController(ResourceController): def __init__(self, app) -> None: @@ -85,9 +113,14 @@ def __init__(self, app) -> None: super().__init__(app=app, entity="C", table=C, schema=CSchema) +class DController(ResourceController): + def __init__(self, app) -> None: + super().__init__(app=app, entity="D", table=D, schema=DSchema) + + app = Api( debug=True, - controllers=[AController, BController, CController], + controllers=[AController, BController, CController, DController], test=True ) diff --git a/src/tests/unit/test_versioning.py b/src/tests/unit/test_versioning.py index c6c8eaf..ceb6154 100644 --- a/src/tests/unit/test_versioning.py +++ b/src/tests/unit/test_versioning.py @@ -58,4 +58,89 @@ def test_no_update_version_resource_through_write(client): response = client.post('/bs', content=json_bytes(update)) assert response.status_code == 409 -# TODO: test this on nested. + +def test_update_nested_resource_through_versioned_resource(client): + item = {'info': 'toto', 'cs': [{'data': 'nested'}]} + response = client.post('/ds', content=json_bytes(item)) + + assert response.status_code == 201 + res_item = json.loads(response.text) + + update_item = {'cs': [{'data': 'nes_updated'}]} + update_response = client.put( + f"/ds/{res_item['id']}_{res_item['version']}", + content=json_bytes(update_item) + ) + assert update_response.status_code == 201 + upres_item = json.loads(update_response.text) + assert upres_item['cs'][0]['data'] == item['cs'][0]['data'] + assert upres_item['cs'][1]['data'] == update_item['cs'][0]['data'] + + +@pytest.mark.xfail(raises=exc.UpdateVersionedError) +def test_update_nested_resource(client): + item = {'info': 'toto', 'cs': [{'data': 'nested'}]} + response = client.post('/ds', content=json_bytes(item)) + + assert response.status_code == 201 + res_item = json.loads(response.text) + + update_item = {'info': 'titi'} + update_response = client.put( + f"/ds/{res_item['id']}_{res_item['version']}", + content=json_bytes(update_item) + ) + + +def test_nested_list_after_release_of_parent_resource(client): + item = {'info': 'toto', 'cs': [{'data': 'nested1'}, {'data': 'nested2'}]} + response = client.post('/ds', content=json_bytes(item)) + + assert response.status_code == 201 + res_item = json.loads(response.text) + + release_item = {'info': 'titi'} + release_response = client.post( + f"/ds/{res_item['id']}_{res_item['version']}/release", + content=json_bytes(release_item) + ) + + assert release_response.status_code == 200 + + v1 = client.get(f"/ds/{res_item['id']}_1") + v2 = client.get(f"/ds/{res_item['id']}_2") + + assert v1.status_code == v2.status_code == 200 + json_v1 = json.loads(v1.text) + json_v2 = json.loads(v2.text) + assert json_v1['cs'] == json_v2['cs'] + + +def test_update_nested_list_after_release_of_parent_resource(client): + item = {'info': 'toto', 'cs': [{'data': 'nested1'}, {'data': 'nested2'}]} + response = client.post('/ds', content=json_bytes(item)) + + assert response.status_code == 201 + res_item = json.loads(response.text) + + release_item = {'info': 'titi'} + release_response = client.post( + f"/ds/{res_item['id']}_{res_item['version']}/release", + content=json_bytes(release_item) + ) + + assert release_response.status_code == 200 + release_json = json.loads(release_response.text) + + update_nested = {'cs': [{'data': 'nested3'}]} + update_response = client.put( + f"/ds/{release_json['id']}_{release_json['version']}", + content=json_bytes(update_nested) + ) + oracle_nested = [update_nested['cs'][0]] + oracle_nested[0].update({'id': 3, 'ca': {}}) + + assert update_response.status_code == 201 + release_json = json.loads(update_response.text) + + assert release_json['cs'] == (res_item['cs'] + oracle_nested) From ed48b124b426952bd0452bda81916bf4d7b6f377 Mon Sep 17 00:00:00 2001 From: Etienne Jodry Date: Thu, 31 Oct 2024 16:38:29 +0100 Subject: [PATCH 2/2] Security refactor, REQUIRE_AUTH mode, doc, tests. --- .github/workflows/ci.yml | 2 +- docs/developer_manual/advanced_use.rst | 21 +- docs/developer_manual/demo.rst | 2 +- docs/developer_manual/permissions.rst | 11 + docs/getting_started.rst | 26 +- docs/user_manual.rst | 21 +- keycloak/3TR.json | 2245 +++++++++++++++-- src/biodm/api.py | 13 +- src/biodm/basics/rootcontroller.py | 44 +- src/biodm/component.py | 4 +- .../components/controllers/admincontroller.py | 1 - .../components/controllers/controller.py | 7 +- .../controllers/resourcecontroller.py | 70 +- .../components/controllers/s3controller.py | 7 +- src/biodm/components/services/dbservice.py | 120 +- src/biodm/components/services/kcservice.py | 86 +- src/biodm/components/table.py | 2 +- src/biodm/config.py | 6 + src/biodm/error.py | 17 +- src/biodm/exceptions.py | 12 +- src/biodm/managers/dbmanager.py | 15 +- src/biodm/routing.py | 58 + src/biodm/tables/group.py | 4 +- src/biodm/utils/security.py | 84 +- src/biodm/utils/sqla.py | 15 +- src/example/.env | 2 + src/example/entities/controllers/file.py | 13 +- src/example/entities/tables/dataset.py | 2 +- src/requirements/common.txt | 1 + src/tests/integration/kc/conftest.py | 13 +- src/tests/integration/kc/test_keycloak.py | 120 +- src/tests/integration/kc/test_permissions.py | 53 +- src/tests/unit/test_resource.py | 40 +- src/tests/unit/test_versioning.py | 21 + 34 files changed, 2630 insertions(+), 528 deletions(-) create mode 100644 src/biodm/routing.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80abeef..e18c9a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11"] # , "3.12" steps: - uses: actions/checkout@v4 with: diff --git a/docs/developer_manual/advanced_use.rst b/docs/developer_manual/advanced_use.rst index d855c5f..8937483 100644 --- a/docs/developer_manual/advanced_use.rst +++ b/docs/developer_manual/advanced_use.rst @@ -183,6 +183,8 @@ a ``ResourceController``. .. code-block:: python :caption: s3controller.py + from biodm.routing import Route, PublicRoute + class S3Controller(ResourceController): """Controller for entities involving file management leveraging an S3Service.""" def _infer_svc(self) -> Type[S3Service]: @@ -200,7 +202,7 @@ a ``ResourceController``. prefix = f'{self.prefix}/{self.qp_id}/' file_routes = [ Route(f'{prefix}download', self.download, methods=[HttpMethod.GET]), - Route(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]), + PublicRoute(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]), ... ] # Set an extra attribute for later. @@ -420,3 +422,20 @@ A lot of that code has to do with retrieving async SQLAlchemy objects attributes # Generate a new form. await self.gen_upload_form(file, session=session) return file + +.. _dev-routing: + +Routing and Auth +---------------- + +As shown in the ``S3Controller`` example above, ``BioDM`` provides two +``Routes`` class: ``PublicRoute`` and ``Route``. + +In case you are defining your own routes you should use those ones instead of +starlette's ``Route``. + +Ultimately, this allows to use the config parameter ``REQUIRE_AUTH`` which when set to ``True`` +will require authentication on all endpoints routed +with simple ``Routes`` while leaving endpoints marked with ``PublicRoute`` public. +This distinction can be important as in the example above, s3 bucket is **not** authenticated +when sending us a successful notice of file upload. diff --git a/docs/developer_manual/demo.rst b/docs/developer_manual/demo.rst index c12e6db..5b78f13 100644 --- a/docs/developer_manual/demo.rst +++ b/docs/developer_manual/demo.rst @@ -21,7 +21,7 @@ we will go over the following minimal example. # Tables class Dataset(bd.components.Versioned, bd.components.Base): - id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in config.DATABASE_URL) + id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in str(config.DATABASE_URL)) name : sao.Mapped[str] = sa.Column(sa.String(50), nullable=False) description : sao.Mapped[str] = sa.Column(sa.String(500), nullable=False) username_owner: sao.Mapped[int] = sa.Column(sa.ForeignKey("USER.username"), nullable=False) diff --git a/docs/developer_manual/permissions.rst b/docs/developer_manual/permissions.rst index 982ff3f..ce7412e 100644 --- a/docs/developer_manual/permissions.rst +++ b/docs/developer_manual/permissions.rst @@ -17,6 +17,17 @@ be provided in a ``.env`` file at the same level as your ``demo.py`` script. KC_CLIENT_ID= KC_CLIENT_SECRET= + +Server level: REQUIRE_AUTH +-------------------------- + +Setting ``REQUIRE_AUTH=True`` config argument, will make all routes, except the ones explicitely +marked public (such as ``/login`` and ``/[resources/|]schemas)`` require authentication. + + +See more at :ref:`dev-routing` + + Coarse: Static rule on a Controller endpoint --------------------------------------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 9054203..7b71f02 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -137,7 +137,6 @@ Keycloak also needs a databse: docker exec -u postgres biodm-pg createdb keycloak - Then you may start keycloak itself: .. code-block:: bash @@ -164,9 +163,32 @@ Once you've created the realm, create the client. Then * **dev**: `http://*` and `https://*` * **prod**: provide the url of the login callback `{SERVER_HOST}/syn_ack`. +Additionally, ``BioDM`` expects token to feature groups. For this, a client scope is necessary. Go to + + * `Client Scopes` -> `Create Client Scope` + + * Name: `groups` + * Protocol: `OpenID Connect` + * Type: `Default` + * Include in token scope: `On` + * Save + + * `Client Scopes` -> `Groups` -> `Mappers` -> `Configure a new mapper` -> `Group membership` + + * Name: `groups` + * token claim name: `groups` + * At least `Full group path` and `Add to access token`: `On` + * Save + + * `Clients` -> `MyClient` -> `Client Scopes` -> `Add Client Scope` -> `Groups` -> `Add - Default` + +Moreover, admin privileges are granted to users belonging to `admin` group. +It is recommended to create that group and at least one user in it, +if you want to create keycloak entities from the API for instance. + .. note:: - Depending on your keycloak version or running instance `SERVER_HOST` may have to be appended with `/auth`. + Depending on your keycloak version or running instance `KC_HOST` may have to be appended with `/auth`. Then you should provide the server with the `SECRET` field located in the `Credentials` tab, that appears **after** you changed access type and the realm public key diff --git a/docs/user_manual.rst b/docs/user_manual.rst index b1701e7..2f4791c 100644 --- a/docs/user_manual.rst +++ b/docs/user_manual.rst @@ -143,9 +143,11 @@ This triggers creation of a new row with a version increment. .. note:: - ``PUT /release`` is the way of updating versioned resources. - The endpoint ``PUT /`` (a.k.a ``update``) will not be available for such resources, and - any attempt at updating by reference through ``POST /`` will raise an error. + ``POST /release`` is the way of updating versioned resources. + The endpoint ``PUT /`` (a.k.a ``update``) is available, however it is meant to be used + in order to update nested objects and collections of that resource. Thus, + any attempt at updating a versioned resource through either ``PUT /`` or ``POST /`` + shall raise an error. **E.g.** @@ -178,15 +180,16 @@ and followed by: * Use ``nested.field=val`` to select on a nested attribute field * Use ``*`` in a string attribute for wildcards -* ``field.op(value)`` - - * Currently only ``[lt, le, gt, ge]`` operators are supported for numerical values. +* numeric operators ``field.op([value])`` -**e.g.** + * ``[lt, le, gt, ge]`` are supported with a value. + + * ``[min, max]`` are supported without a value .. note:: - When querying with ``curl``, don't forget to escape ``&`` symbol or enclose the whole url in quotes, else your scripting language may intepret it as several commands. + When querying with ``curl``, don't forget to escape ``&`` symbol or enclose the whole url + in quotes, else your scripting language may intepret it as several commands. Query a nested collection @@ -366,6 +369,6 @@ otherwise. - Passing a top level group will allow all descending children group for that verb/resource tuple. - - Permissions are taken into account if and only if keyclaok functionalities are enabled. + - Permissions are taken into account if and only if keycloak functionalities are enabled. - Without keycloak, no token exchange -> No way of getting back protected data. diff --git a/keycloak/3TR.json b/keycloak/3TR.json index bc729c2..b7ce8ed 100644 --- a/keycloak/3TR.json +++ b/keycloak/3TR.json @@ -1,4 +1,1949 @@ -{ +[ { + "id" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "realm" : "master", + "displayName" : "Keycloak", + "displayNameHtml" : "
Keycloak
", + "notBefore" : 0, + "defaultSignatureAlgorithm" : "RS256", + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 60, + "accessTokenLifespanForImplicitFlow" : 900, + "ssoSessionIdleTimeout" : 1800, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "clientOfflineSessionIdleTimeout" : 0, + "clientOfflineSessionMaxLifespan" : 0, + "accessCodeLifespan" : 60, + "accessCodeLifespanUserAction" : 300, + "accessCodeLifespanLogin" : 1800, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 300, + "oauth2DeviceCodeLifespan" : 600, + "oauth2DevicePollingInterval" : 5, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "e5bc5b15-a9f5-402b-b35e-c501283276aa", + "name" : "default-roles-master", + "description" : "${role_default-roles}", + "composite" : true, + "composites" : { + "realm" : [ "offline_access", "uma_authorization" ], + "client" : { + "account" : [ "manage-account", "view-profile" ] + } + }, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "attributes" : { } + }, { + "id" : "1b1df0ed-1212-4cea-b9a8-d8c2a456b54d", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "attributes" : { } + }, { + "id" : "d05dab67-dab1-41ad-8eb2-45518afbb208", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "attributes" : { } + }, { + "id" : "ae67fabe-5e7e-4692-8c47-076d1fc552a1", + "name" : "create-realm", + "description" : "${role_create-realm}", + "composite" : false, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "attributes" : { } + }, { + "id" : "1e086cfe-0781-4f4c-96bc-b5e37e5e273f", + "name" : "admin", + "description" : "${role_admin}", + "composite" : true, + "composites" : { + "realm" : [ "create-realm" ], + "client" : { + "3TR-realm" : [ "query-realms", "impersonation", "view-events", "create-client", "query-users", "manage-realm", "manage-identity-providers", "manage-clients", "query-groups", "manage-events", "manage-authorization", "view-authorization", "view-clients", "view-identity-providers", "manage-users", "view-realm", "query-clients", "view-users" ], + "master-realm" : [ "query-groups", "manage-events", "create-client", "manage-clients", "manage-users", "view-clients", "view-events", "view-authorization", "manage-realm", "view-users", "query-clients", "query-users", "impersonation", "view-realm", "query-realms", "manage-authorization", "manage-identity-providers", "view-identity-providers" ] + } + }, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b", + "attributes" : { } + } ], + "client" : { + "3TR-realm" : [ { + "id" : "3171b60c-2aac-4cdf-89c1-9f6b48703da6", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "fe0b5870-f530-40ad-9fe4-7de9fe602ec7", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "dbb471c7-9656-4767-a7ff-abeaba41fd3e", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "11c6ec5e-2852-494f-a4d7-697060b83937", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "c26c9ac1-aacc-44ab-8ff7-b3b5b4057700", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "3TR-realm" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "18ae997b-a37a-4a24-8e97-bfd3e7bf49ff", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "aeeec425-14b6-440b-ac18-5b7d512db412", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "baf107e2-b2f6-47f2-bb39-606b5b82d403", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "7aa73d7f-0461-4a09-8320-89d3709561c0", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "95748184-57f9-40bb-9e42-c4cb26b6d1f4", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "b0a8bca9-a732-44fa-b3eb-de5fff5978f7", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "ccb87cd2-a0e8-438d-bd6f-8f648330f18a", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "ad6f13b4-3a4c-43b2-8c19-46ceabed9473", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "a19ef677-c127-4466-b7b4-7199c1f7d517", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "84199dfb-5556-432a-9ce8-617d46e809f2", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "05180a34-a763-4bf9-89a9-7cc37d353dba", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "4f080ea7-b063-4bdd-ac1e-4a70268fb3ac", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + }, { + "id" : "dea6a335-307d-42bc-9b7c-4dd6e2ddd250", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "3TR-realm" : [ "query-groups", "query-users" ] + } + }, + "clientRole" : true, + "containerId" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "attributes" : { } + } ], + "security-admin-console" : [ ], + "admin-cli" : [ ], + "account-console" : [ ], + "broker" : [ { + "id" : "c4d32102-4eba-4aa3-9419-99ff314a1487", + "name" : "read-token", + "description" : "${role_read-token}", + "composite" : false, + "clientRole" : true, + "containerId" : "8a7961b4-559e-4ca4-8ec0-b08a0fb325fd", + "attributes" : { } + } ], + "master-realm" : [ { + "id" : "e08cb887-1eb8-4292-b6a8-713687be3f6f", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "652d016e-1471-4193-979b-82364943e043", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "8af15d6c-3e1e-45c2-92ab-a12565a9fbd4", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "12aa10fd-ef47-4a83-88a5-7781513acc29", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "766da704-ac81-4e16-bbb1-fd5e35dad51f", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "88d1c6fa-9385-4ba5-ba46-ff629bbd2d1c", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-groups", "query-users" ] + } + }, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "63080507-7535-40bf-9719-964bcdf07c64", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "f14a5392-0e38-4f98-b947-25927a543436", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "d00e0a39-f6b1-4bc8-a0fd-c9b4f05f4cad", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "1a131a5a-693e-4ecb-8ef7-03d166907dec", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "9b1a32a0-714a-4286-8d7e-63f73f688bbb", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "2e3ffc69-23b0-4a0d-a6f5-2034fa7b18db", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "8a7c33f2-20a3-4dad-b0a8-5c99b26b46cc", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "ab71eb2f-74e4-40ce-beca-4dba998fffaf", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "500f2e72-eef4-4b26-909a-b21325c9e26b", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "81cf0848-c7dd-45ba-9440-a4cd8bd7a648", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "a711bcc4-3863-42b2-aa2e-cc875a868754", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + }, { + "id" : "62186eab-58c2-49ec-ac09-25c979467327", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "attributes" : { } + } ], + "account" : [ { + "id" : "6b1cb0a0-a3e7-46ee-8cef-9c721d36d627", + "name" : "delete-account", + "description" : "${role_delete-account}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "554beba2-ccec-4799-a996-2a018165253e", + "name" : "manage-account", + "description" : "${role_manage-account}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "manage-account-links" ] + } + }, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "ff04c74b-3afb-4ac9-8b09-f777367a8c1f", + "name" : "view-applications", + "description" : "${role_view-applications}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "26748dc1-5e14-4319-8905-1fd68d79877a", + "name" : "view-consent", + "description" : "${role_view-consent}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "58602ab9-53b1-41d8-8b01-986922626fa2", + "name" : "view-profile", + "description" : "${role_view-profile}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "bf1ca38c-c645-4e14-9773-99282143ed5b", + "name" : "manage-consent", + "description" : "${role_manage-consent}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "view-consent" ] + } + }, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "e9d3ac60-8f07-4c12-befe-0e1ba84c74d4", + "name" : "view-groups", + "description" : "${role_view-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + }, { + "id" : "166c202e-11df-462e-b883-ee4cf419a280", + "name" : "manage-account-links", + "description" : "${role_manage-account-links}", + "composite" : false, + "clientRole" : true, + "containerId" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRole" : { + "id" : "e5bc5b15-a9f5-402b-b35e-c501283276aa", + "name" : "default-roles-master", + "description" : "${role_default-roles}", + "composite" : true, + "clientRole" : false, + "containerId" : "c17805a5-afc5-43dd-9680-baf38a3d707b" + }, + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpPolicyCodeReusable" : false, + "otpSupportedApplications" : [ "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName", "totpAppFreeOTPName" ], + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "users" : [ { + "id" : "20a44f55-cf00-462b-8834-7d8ed57f58f5", + "createdTimestamp" : 1729858577840, + "username" : "admin", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "id" : "faa829a8-4fdd-44dd-87b5-8ccac65c7a0c", + "type" : "password", + "createdDate" : 1729858577909, + "secretData" : "{\"value\":\"nbvuEBfXpIX9VJLPSnTtRF4MCvD5MbplDk7Y6lG+f0k=\",\"salt\":\"lWKNqfDtFmQnnPRfTyiEnw==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-master", "admin" ], + "notBefore" : 0, + "groups" : [ ] + } ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account", "view-groups" ] + } ] + }, + "clients" : [ { + "id" : "aeda2bbd-bf3c-44b9-b374-1844a53a6fdb", + "clientId" : "3TR-realm", + "name" : "3TR Realm", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ ], + "optionalClientScopes" : [ ] + }, { + "id" : "16dbe890-a414-4de5-a7e2-a1d59af3bc5e", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "2983c186-3e0e-422b-819d-13e5e3311771", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "6b4f6722-929f-4e5b-b8d1-6ff36adfb375", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "89c393a6-bf75-41e2-93f3-4bc8b83facf0", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "8a7961b4-559e-4ca4-8ec0-b08a0fb325fd", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "762c406f-67c2-4daf-9c86-86f5fb99d2ea", + "clientId" : "master-realm", + "name" : "master Realm", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "0bd00a0f-53a0-47b6-b6dc-4c3a984f28d0", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/master/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/admin/master/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "77170871-94b9-4de1-90e5-6f608dcbe0df", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "b0eab430-6d64-4e2a-b569-6a00283db5a2", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "3eb3f8d8-0e5a-4574-88e0-c13c0caa1491", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "fbd541fa-1689-43b4-982b-b81b6b747e63", + "name" : "acr", + "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "5492a959-6313-4f76-88f4-f62679dd5582", + "name" : "acr loa level", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-acr-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true" + } + } ] + }, { + "id" : "9d4047c2-b546-4547-9c05-347c4eb20fcc", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "ddc8ae5c-53f4-46ef-a096-d38a8f6607e5", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${addressScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "be606f85-50e3-4d1c-ba0c-ee5ea50774ec", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "4fd334d9-00d0-4d41-b95e-0c9aa37a4135", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${emailScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "40882f8c-e34d-4b0c-b8f2-860af85e64c7", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" + } + }, { + "id" : "ddaae589-ed76-4dc0-89ae-099d4b3392ce", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" + } + } ] + }, { + "id" : "dc9eb379-ec73-4760-b320-153bb6388377", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${rolesScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "993ad648-c235-4e73-b92d-6acc2b7a8c03", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "87311d3b-2ffa-4d8e-9e82-aaf90e3baf12", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + }, { + "id" : "0b8d45ef-cae3-4163-890f-d24e7938e60b", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + } ] + }, { + "id" : "ae368b7d-ad89-4430-ba40-74cdffaa2323", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${profileScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "01bb741c-f07f-4a22-9b7f-439251894f2b", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "f37cf59e-477a-411b-9036-c3126a71ec5a", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "2a3518b7-535b-4c2d-a0c7-2a00cb9616b6", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + }, { + "id" : "9ddc33b6-2c64-475c-8509-9636ea95b046", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "ce45052b-fcd1-4702-b173-a04cd13fb019", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "6c77b0b4-3913-4b36-9d0f-937e751ee2bf", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" + } + }, { + "id" : "0ae5d250-0e5f-417e-a96f-49064a233d5f", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + }, { + "id" : "5abd4c42-63cf-4c91-95bd-eb77f78819bb", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" + } + }, { + "id" : "6f3a292a-a7ad-474c-804a-fc378b648385", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "bc82dd77-43a2-4368-b207-998664f7fef0", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "b009c1d6-df3c-42a7-822f-c1afe4630696", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "6c455828-e11e-4a29-b7e8-80f90a55a4c5", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "long" + } + }, { + "id" : "4a52eabe-e0dc-450a-9a4f-18d0a260b355", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + }, { + "id" : "ac72fa74-149c-4288-9fe4-f124e2eb97be", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "20f494e9-170c-40e2-a5b0-8eb8a4cbe07d", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "cee3f724-1742-478a-9086-8a4c36ec6aab", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { } + } ] + }, { + "id" : "b67b494a-dd48-4d4b-8bbc-64569f943686", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${phoneScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "bcfe6ac6-74a2-4da5-b89c-5eeba0a4b8d1", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + }, { + "id" : "6b728bc5-299b-4755-a201-3625d5ac979f", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" + } + } ] + }, { + "id" : "cad3163b-a8cd-4ca3-ae24-aea3511dc37d", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "92aa7a91-dd44-4df0-8071-3ad34ff5fadf", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "multivalued" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" + } + }, { + "id" : "5ddfd742-98c3-4b07-8020-7ca5adf14ce3", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" + } + } ] + } ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "referrerPolicy" : "no-referrer", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "xXSSProtection" : "1; mode=block", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "identityProviders" : [ ], + "identityProviderMappers" : [ ], + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "d99f7318-7580-47b6-8b36-a27c4d3f8bf8", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper" ] + } + }, { + "id" : "ccc92fcd-7716-49e8-b308-9fa03815d9d2", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] + } + }, { + "id" : "080bbf25-1d6d-4a92-81b5-6bae2dca3802", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "7e1b1513-a5d6-491a-985f-525a2095a428", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "222787ea-8d69-40eb-bdf6-8ca908b08d50", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "0e5b42b9-8a88-4875-a5b0-6b92060d1c0c", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "a1f29bd0-93cf-45a1-9886-ab9b8c6889dd", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "48b19a55-b710-42df-bda1-e49dcbf93e91", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] + } + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "89329efb-97a6-4dc7-ab9c-6d2249218387", + "name" : "hmac-generated", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "010dfc53-f3a7-463e-9342-2bf1d5bc29fe" ], + "secret" : [ "t7-hMDpihf8KU4SFgvi0vdh6Lri6Ocme_4k0JKIuZz-FSMjhTLf3FB_X5PqVt9xEH7ILUXrNzPsCmWI839zppg" ], + "priority" : [ "100" ], + "algorithm" : [ "HS256" ] + } + }, { + "id" : "f99f7648-b448-450d-a1bb-5b6406c2a542", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAk7xdeWCwIxLDb0b1hydttl5TmG/6YZIhAL8K/1ICTEPpL3mcTIhw2oIXLH2rTPwQc3QwkXFQciibaASgYptJ5K6sLZLsrr8SOuTPXUOPfqwoBWK2g5PGP9XibhcKkjda1C+him3TRIkHG6r8fE9jFqJdkCSpkVo2JWGzYjH5ZsoUJBoQVRuiQJ38Fbb9SKs3bpfbxhRQ2NhVncixtidpqoX4aU8s9i9aXGCS+EiEFJ/isExMuNCfenu5ZNCXD3NbOp2V95Ugx7ww/BxRUv+oOZXBIz7CaZbpwOOQyBsIWPU3V3+xSpu6y06uKHsrWpwQFYGvfX+LQ49/O82IzqDAVwIDAQABAoIBAAcO2bxhxSh3zgRz9Gj3lkM/MGk4+FJPA+qgNetKxLdWCz7sZW42moWl72t9uYBdDoAljeh07G33yKzog60SVC5rtegbe9lBYaKUqd9/ycw4d1UCiUux9Ke6FS5DRYsEv/8hI2uUoaJFlaRZv+REeqxrJ2MqjTtXfvy8NTC8yHiDnmiiFvyGIWpD+9ZqcX1LFNb3HFwMMCW45dYigZQBLiCTjmZq7/utbR6YZQ/v/aDDX5M/gUndRczM39ARpLxZbt/zwjVCKtK4YupifwdFjyqZ4DgoPZWzqHMN3M8cQkjZPmA33mh+JLUlVLr5tO/BDu09HRSVzXQRj1qII/EGwGkCgYEAyZDvZLWkeSeQgw9wZR1jDyCZWXCO+qZ5NmuEyC3+bBNSdmE/RITA7Eyq9WvqI6M8tft7XnTWZOMYzXJ5DA4UpgSdz2GXRH1aDuiw2qxufFfHtZg6HMYiWNR5No1cQwA8Bo3B93Ny25Z53bY7hxrf8yJ8T+Oa+jI3+1/VFabZQt0CgYEAu6HszpE6qTAosP/gEkNGBU5oM78yFgUnJnh4wS5Sg/J5f76Hk4XWscJmvfsnOOPGFIkQ/TKt+K80wdflLCksu3mlqOePeUjk+9RYyai6BGCdp/P22aT9LgOHY7dYsscPbqUXXmZdBTPY1KxDvKiOMDiZF9Z9IiA4vNcwF3xD+sMCgYAdgq255eolnshGl+0RsMK/BTvOX29ffR4D/KvdDvbP5ehN0qELrA/+yJ7C/cCy5QRPdHk0dBCrhqAr/BWC0VDumYBtS1F1QpD728+AxHwMkmF30ci4S73dmYYBmaKnYJJpNzntu6ZWReqjrjl651FgbG0c2SrodI/Dqt0spVfrtQKBgQCaDiQxonpIiZsPYLdoJ0YpeywBOL116PsxEf8Lle34GjD52sTUMjKJtvTGjZyxkAqFt9h0G5VNtwUZFxs5/ACXWRTULnPVgC1KOxo/UMKSb4VibePC5T1e91TFYbd46gnYrcIXJvK/H8erLllbYAWGwCGqudf6GfybyA7baZMjIwKBgG5AnhLd8LIWES3ymZsG8y+Cvt/LETLtFOji36XCObxAdEwsl3Fgj8lhJDgI/aiPj139thSVm9VdjaW4MfFXOL5Xmopk7seL1GZIvcJU+9F4yAjCuEJRP5CH2xYBbdcmHFWHLJoASnFbUd2qNYK6zHpgMYCS8qnClW64hKYtrxCL" ], + "keyUse" : [ "SIG" ], + "certificate" : [ "MIICmzCCAYMCBgGSw5salzANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQxMDI1MTIxNDM1WhcNMzQxMDI1MTIxNjE1WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTvF15YLAjEsNvRvWHJ222XlOYb/phkiEAvwr/UgJMQ+kveZxMiHDaghcsfatM/BBzdDCRcVByKJtoBKBim0nkrqwtkuyuvxI65M9dQ49+rCgFYraDk8Y/1eJuFwqSN1rUL6GKbdNEiQcbqvx8T2MWol2QJKmRWjYlYbNiMflmyhQkGhBVG6JAnfwVtv1Iqzdul9vGFFDY2FWdyLG2J2mqhfhpTyz2L1pcYJL4SIQUn+KwTEy40J96e7lk0JcPc1s6nZX3lSDHvDD8HFFS/6g5lcEjPsJplunA45DIGwhY9TdXf7FKm7rLTq4oeytanBAVga99f4tDj387zYjOoMBXAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEVARuJVflPo6C9XGoh4klPMy5m5Wk0H81mtC+AqTqKpfXr+PE9mDzxayL4UcVNLrdfVIIhuOw2M4eustlcdf+EDEMfC6a2OHxsZM6z+qvdjQRW44tgjl8UpJZt4BOIaZh0o72KA/dafLga8ykvpKVXY1KzkTmxtMA9CZFq4YINJeGnrYz+k/OvEUeCJlS+fdz0cHRLpahCZAXKvtw3aSsWv0yNTVF/iDgzM6dufJc56am29rrN58m3VQ/cIgbfUU8BoQL7P8gY3cOj+OFDRg7TwcmVm0r3edc7ZLPlatxS5W4hWpgkeVr+yFugnOKn4ldPyhtTtmnBMm3QKxXEcpxY=" ], + "priority" : [ "100" ] + } + }, { + "id" : "ff1bd561-7680-4683-8be6-eac2bc649544", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "fb011f7a-4233-4260-9d0e-bf374257a742" ], + "secret" : [ "0cX7jfLJJ4OwKriWDE82nw" ], + "priority" : [ "100" ] + } + }, { + "id" : "48dfe311-93b1-46b4-9bbc-209044e4e135", + "name" : "rsa-enc-generated", + "providerId" : "rsa-enc-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEogIBAAKCAQEAnFAHCoKBmhyEYc2jRr8KSnXa5pAZDj3KRE/Zk6+NUgbGAVjGeeo6VpcHVDCU2q7nhxvaRp4rVBf+Y/uvL8I57tl3amcXNmSoowKl/Hzmz0jdcZiUw6FkgbVvbndUya7Kh9YoKhd5Aq5yxTCfSDiUSEX5pdb3uvsi85FlVJmPhSDevgLHydaGLED5Fc9atpcXLvrRQ0NtT2DZ/w1qtrOzbrZBjNHiPuuYuzc7asm9rlQpseKrs4/jYqDmEuVU/83Ifx/LmJb13XZWFEoMYvCzLclkukv+hDX62QDxjMccq4ifZoNVyh9i69Qsudd25sCwq38MOPKZ+JCmDm0USZHn8wIDAQABAoIBACqZsOGPYczjerzA83/DbwWOCyONIBb+hhKKBI04afZx/CK/1A/D4zRYItoyB092HYl38MwQLVTU2TLclAkbEPjkcaQhBnV/h2otvZkLXUge3qMn51tNr/udvAnKIeR5a58LoZSfIw61BnhxYOeo69iqoXguFwPxj7v74zbkRvYL6tPTUr1VCmwr3pf9s7wreklTIdH5d1hP4+Uv6ZkMT0piWhZI8wZGnRGfO3kYkuOK+37lLoZyjzbUrg/uPzPrEsL7ktQo5hw1mD0b46ZZE3ErEVszt0WIVjglum7ekVvD2C6j4s0TdrChHGdhmfPc7TDtbsNpbhIqeRPc0asJSFECgYEA2mGdWn42MKBO/gMSm+uqpJhbCrzJ1WlgclCWoY7oMpuVOnX8RPFuyuSHr7OoXE49R6aH+LW1cRc+2jwj+TNlOjNhuruMJGWGXEz2TMK9vz0ZZzh7DUtCLsaUQIVNBtpUjz3hKsmsudLR9itoDh6VPu1TR0uBOHpjSFa4iuUkV6kCgYEAtz0/ptF8BKJmlRFiwL5ob13GGX836lgwPsnitzJnEVVc52As5bzqrNdE4QF5eHpzDq3D2oqZ6QXMWtyc78/ffq/ArVqaNw7/ASZlPPawDbFi1Ccd1N5REJ6LNlhmhH25wt5nAn9cqbLGX21BI029YZPSmCdcAOjDYpYCkl3klDsCgYAfG/jGg2RE2RFnrhhgjdnpSKrvhKVb+X77ye8rZKg/TVqc0WH2kE4wKQ4LQZSiUaL5KggErh4C1kTl4dteDKxG9jrd1wnHxY62Z2BO3w8YxyNvSOR+qFHtR+ympFasuz2AilghOEmazyWJ4/UWzdSE+ln6tg7adNTf4Tq4zpu2wQKBgC14hn4YB+WCg2BEgzP/TB0usQUMu8xse/Ro1tjNKiR0AWztQdb0zWt8s/v+CK2r/TdMSYjG0jCwHqkBi/Q6qmReqrqZ/CDjmMYpSAAb205akYLB/jYfwRAVt8gRoccJB/rig79r6Yu28GEn1H01QmSfcSgOxFb5a9rgDN1TbXCXAoGAcJ5Hj4g8sEH+H3jUQMR8sSQf/eC6rHD6Sen3wVK/sZxRB4Mi/hBAndPSqPMZXjudKcL61D3PM4o12uRKRkyzldK31114bQNcCByQVaWTzVzH2zElHeLzqALV2m2vGRgBWxIw4jkoPIdfWiiB9ZiQZo4KqQVOwScvqderhqe9ZWs=" ], + "keyUse" : [ "ENC" ], + "certificate" : [ "MIICmzCCAYMCBgGSw5sb/DANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQxMDI1MTIxNDM1WhcNMzQxMDI1MTIxNjE1WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcUAcKgoGaHIRhzaNGvwpKddrmkBkOPcpET9mTr41SBsYBWMZ56jpWlwdUMJTarueHG9pGnitUF/5j+68vwjnu2XdqZxc2ZKijAqX8fObPSN1xmJTDoWSBtW9ud1TJrsqH1igqF3kCrnLFMJ9IOJRIRfml1ve6+yLzkWVUmY+FIN6+AsfJ1oYsQPkVz1q2lxcu+tFDQ21PYNn/DWq2s7NutkGM0eI+65i7Nztqyb2uVCmx4quzj+NioOYS5VT/zch/H8uYlvXddlYUSgxi8LMtyWS6S/6ENfrZAPGMxxyriJ9mg1XKH2Lr1Cy513bmwLCrfww48pn4kKYObRRJkefzAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABznnZv6xsacq3xVXuOrpRnd/zVbcH6X2+cxry+BCYK0r2paMK9IIA5A6tefUEfMAsheml2rrrU9R7Acna9NA50zlsQbqyKGoyWEX12SLuZ/vG/l4kUoh2U10WaDFPB+3Dj7duoXMaxCuzgzhIrw1YqqIKXmZki7LqPKQmU5Jd65JARvdifFrjsEcCk2VtEr8XwVuMj7tsAzv3JkQ8YZxpuLnQeR37AY/J3mpI7RRl8iqAOolnYi6Z5uAMas1cPRRgTcr8Lg9r88A66dZpXJrQ9MyNbuowvXAYc7RAHR3+8Vn+z7WPdt+MB1LCllqs7SWlyF24xUw4nmKGuBDBqVLRk=" ], + "priority" : [ "100" ], + "algorithm" : [ "RSA-OAEP" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "7c855eed-d07d-4a8b-9635-084fa525266d", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false + } ] + }, { + "id" : "2f6b5596-ce66-43bd-a4b3-9c9d2e8305ee", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "bca473e4-0005-461b-b22f-cadb14b28066", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "9a30bc03-1771-422c-b48b-3a3a9140c1fd", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "bc2a8484-cf14-4f13-aea2-4b51d084c85a", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false + } ] + }, { + "id" : "c8a00021-54f6-4722-b5c5-306cc94fc881", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "ae55f464-69c8-47e0-9cef-ab3c2cd11852", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false + } ] + }, { + "id" : "94900162-9650-466b-b2ad-5a35f9341964", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "4fb3f0d0-6ea2-4e7d-b32a-39cb3aeb2dc8", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-spnego", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "identity-provider-redirector", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 25, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "forms", + "userSetupAllowed" : false + } ] + }, { + "id" : "2bc6cb53-3b6c-4efa-a331-8d33f26cd573", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-secret-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-x509", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "e96f4f56-1759-4268-a10d-7d04ff78a68b", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "894f24cd-0945-4459-84fd-c7c830c6c01b", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "26eb3488-5154-4c31-98fc-219c0f4bfcd4", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false + } ] + }, { + "id" : "c3a9c7dc-008f-4385-b89c-b0221c9976fb", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "d6b1355c-1faa-4071-a14b-bfa5ffb8814c", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : true, + "flowAlias" : "registration form", + "userSetupAllowed" : false + } ] + }, { + "id" : "d44874e2-c1b8-4c0f-a87b-4e29dbb6c627", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-profile-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-password-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 50, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-recaptcha-action", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 60, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-terms-and-conditions", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 70, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "0ffc7086-81e1-4b80-b111-f7790e97e31b", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-credential-email", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 40, + "autheticatorFlow" : true, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "15eab508-3c28-4dd0-a2ec-c9f7117c58a3", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "1308bff8-b987-4449-b09a-9759b1e936ed", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "82d95c96-51a1-403f-8d15-404b60f1352d", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "TERMS_AND_CONDITIONS", + "name" : "Terms and Conditions", + "providerId" : "TERMS_AND_CONDITIONS", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "delete_account", + "name" : "Delete Account", + "providerId" : "delete_account", + "enabled" : false, + "defaultAction" : false, + "priority" : 60, + "config" : { } + }, { + "alias" : "webauthn-register", + "name" : "Webauthn Register", + "providerId" : "webauthn-register", + "enabled" : true, + "defaultAction" : false, + "priority" : 70, + "config" : { } + }, { + "alias" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless", + "providerId" : "webauthn-register-passwordless", + "enabled" : true, + "defaultAction" : false, + "priority" : 80, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "attributes" : { + "cibaBackchannelTokenDeliveryMode" : "poll", + "cibaExpiresIn" : "120", + "cibaAuthRequestedUserHint" : "login_hint", + "parRequestUriLifespan" : "60", + "cibaInterval" : "5", + "realmReusableOtpCode" : "false" + }, + "keycloakVersion" : "22.0.5", + "userManagedAccessAllowed" : false, + "clientProfiles" : { + "profiles" : [ ] + }, + "clientPolicies" : { + "policies" : [ ] + } +}, { "id" : "2d36e09e-c607-43f5-92a2-6e488c9835fb", "realm" : "3TR", "displayName" : "", @@ -338,85 +2283,13 @@ } }, "groups" : [ { - "id" : "fdd11d1a-07b4-4bc9-af0b-b070299522e6", - "name" : "g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_1", - "path" : "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_1", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - }, { - "id" : "e6361d07-4e92-4ebd-858b-ffb14f7fc78b", - "name" : "g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2", - "path" : "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ { - "id" : "b570e121-bbaf-460d-9d03-95e4dc5ac258", - "name" : "child", - "path" : "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2/child", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - } ] - }, { - "id" : "e3dd83b4-7ec3-4e6f-8664-1af85944cc59", - "name" : "g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_3", - "path" : "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_3", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - }, { - "id" : "a71b4b81-c672-492d-9118-4fc165040c53", - "name" : "g_test", - "path" : "/g_test", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - }, { - "id" : "051451eb-0909-4a55-8698-6b154bb7684b", - "name" : "g_test_wg1", - "path" : "/g_test_wg1", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - }, { - "id" : "2ebf3799-0cb6-40ab-bd3a-e4bf6b12d190", - "name" : "g_test_wg2", - "path" : "/g_test_wg2", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - }, { - "id" : "ebee84c5-3862-4829-8b8c-cbcd59cca86a", - "name" : "g_test_wu", - "path" : "/g_test_wu", + "id" : "a634bda5-17c1-48b3-8815-50e56a1a7917", + "name" : "admin", + "path" : "/admin", "attributes" : { }, "realmRoles" : [ ], "clientRoles" : { }, "subGroups" : [ ] - }, { - "id" : "c558ae25-257b-4b46-9159-e0bcbe1b891b", - "name" : "parent", - "path" : "/parent", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ { - "id" : "8ec195ca-bc0e-4ec0-8baf-0067c6e53e88", - "name" : "child", - "path" : "/parent/child", - "attributes" : { }, - "realmRoles" : [ ], - "clientRoles" : { }, - "subGroups" : [ ] - } ] } ], "defaultRole" : { "id" : "8ec1eb4b-e21f-4deb-8bf6-74e3810dd724", @@ -434,7 +2307,7 @@ "otpPolicyLookAheadWindow" : 1, "otpPolicyPeriod" : 30, "otpPolicyCodeReusable" : false, - "otpSupportedApplications" : [ "totpAppGoogleName", "totpAppFreeOTPName", "totpAppMicrosoftAuthenticatorName" ], + "otpSupportedApplications" : [ "totpAppMicrosoftAuthenticatorName", "totpAppGoogleName", "totpAppFreeOTPName" ], "webAuthnPolicyRpEntityName" : "keycloak", "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], "webAuthnPolicyRpId" : "", @@ -456,6 +2329,26 @@ "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], "users" : [ { + "id" : "37d13c86-90b0-46d6-9642-2903513e875c", + "createdTimestamp" : 1729858915050, + "username" : "admin", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "id" : "61e3b74a-a8c4-4b1e-8050-e5469d56b77e", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1729858937091, + "secretData" : "{\"value\":\"9GCa4Q9etvS2BEVg1WEm0zJ3+2YUAKboV3zMlqu9aoY=\",\"salt\":\"ARMv2kJR835R9kqcggmNMQ==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-3tr" ], + "notBefore" : 0, + "groups" : [ "/admin" ] + }, { "id" : "7cb5d1bd-f8fc-429b-bfe0-7cba246009d8", "createdTimestamp" : 1724858335452, "username" : "service-account-cellxgene", @@ -486,160 +2379,6 @@ }, "notBefore" : 0, "groups" : [ ] - }, { - "id" : "383204b1-a53d-457d-b96e-c250f9f234f7", - "createdTimestamp" : 1724857854413, - "username" : "u_e1cc47ae-2b18-46ab-943b-77ea91db88cc_1", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "e1321e37-2d6d-4bd7-8020-e0fe3eba5e65", - "type" : "password", - "createdDate" : 1724857854439, - "secretData" : "{\"value\":\"QU+9hRFgBYS5hpZlypwBacFz1RHkfJVXbgWm0aqXdJQ=\",\"salt\":\"bxhDVVeG25bwmSRdVOFQlw==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_1" ] - }, { - "id" : "a25e8239-99b0-4f7d-a76e-8f3c487e30b9", - "createdTimestamp" : 1724857854475, - "username" : "u_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "2c114819-3aa7-4623-b181-94b8e61d7c0b", - "type" : "password", - "createdDate" : 1724857854498, - "secretData" : "{\"value\":\"RVnazOYvI4ozzXb2wiAdly/un78x53QkxEbChmrMGmA=\",\"salt\":\"zXX5v8JyqybWqZYFBKBoJg==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2" ] - }, { - "id" : "6fb338c2-934a-493b-a508-926f7f83395e", - "createdTimestamp" : 1724857854592, - "username" : "u_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2_child", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "b0639655-4e65-4aa4-a12f-97c147554216", - "type" : "password", - "createdDate" : 1724857854625, - "secretData" : "{\"value\":\"pkJfANwUe89A6wjibRPCISwNC04brjwGrelh1xAdkVY=\",\"salt\":\"HJJoQjIwNtu5EZYPiD6Rpw==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_2/child" ] - }, { - "id" : "e4c0afa9-56bd-40c2-867a-e9d3d1afe91e", - "createdTimestamp" : 1724857854527, - "username" : "u_e1cc47ae-2b18-46ab-943b-77ea91db88cc_3", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "d3d50a3f-a88a-4607-a334-53cd91958519", - "type" : "password", - "createdDate" : 1724857854550, - "secretData" : "{\"value\":\"VfvlIvrwls3WWIS02khyHQ5OB4d2AgSxjQuCm5ouz9A=\",\"salt\":\"+NmhaY4ZDQVQ5RNF955+1w==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_e1cc47ae-2b18-46ab-943b-77ea91db88cc_3" ] - }, { - "id" : "4cb3f7c4-ab65-4e1e-bcd7-f7fefdab7929", - "createdTimestamp" : 1724857853538, - "username" : "u_test", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "jack", - "lastName" : "doe", - "credentials" : [ { - "id" : "fb9c6121-57f5-44fa-8af5-80d7048d5279", - "type" : "password", - "createdDate" : 1724857853560, - "secretData" : "{\"value\":\"hK9DKm2EtUh/7EkopG026OzCRsouw520mpres6RRUSc=\",\"salt\":\"5FEBdITsaWQeYHn45Hk6bA==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ ] - }, { - "id" : "270c84c8-7383-4cb6-a960-f8d59f8a36b2", - "createdTimestamp" : 1724857853936, - "username" : "u_test_wg", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "968648f4-35d6-40ae-ad1b-2361a1b1edd3", - "type" : "password", - "createdDate" : 1724857853966, - "secretData" : "{\"value\":\"YpRIhRyoeAATf8irVbTaJgbB1sarukltAqEsbc1V+oI=\",\"salt\":\"sLrbVyDV1ysfktibkDiElw==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_test_wg1", "/g_test_wg2" ] - }, { - "id" : "2f04697a-ceba-4d04-aa80-bb76d8584090", - "createdTimestamp" : 1724857854068, - "username" : "u_test_wu1", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "528f1312-d40e-478b-a824-6e80bd5bcb44", - "type" : "password", - "createdDate" : 1724857854091, - "secretData" : "{\"value\":\"UAuSRDZ6ObsCbtcQASNciAXlSeHQpwIK4MzPHYiW478=\",\"salt\":\"uefKbd+utYFdiTjeixNZLg==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_test_wu" ] - }, { - "id" : "cd0d136e-e1c9-4696-84cd-62bd695ecd73", - "createdTimestamp" : 1724857854114, - "username" : "u_test_wu2", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "credentials" : [ { - "id" : "092e91dc-2ae4-413c-882b-5def4816b20b", - "type" : "password", - "createdDate" : 1724857854135, - "secretData" : "{\"value\":\"XbXmc5UPNNsEcosUHK0ujWZmkeuiXUL7wvmalVNHF5Y=\",\"salt\":\"VX1OzQyIvqv6c47OE5EV2w==\",\"additionalParameters\":{}}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "default-roles-3tr" ], - "notBefore" : 0, - "groups" : [ "/g_test_wu" ] } ], "scopeMappings" : [ { "clientScope" : "offline_access", @@ -803,9 +2542,10 @@ "protocol" : "openid-connect", "attributes" : { "oidc.ciba.grant.enabled" : "false", - "oauth2.device.authorization.grant.enabled" : "true", "client.secret.creation.time" : "1724858335", "backchannel.logout.session.required" : "true", + "post.logout.redirect.uris" : "+", + "oauth2.device.authorization.grant.enabled" : "true", "backchannel.logout.revoke.offline.tokens" : "false" }, "authenticationFlowBindingOverrides" : { }, @@ -819,6 +2559,7 @@ "consentRequired" : false, "config" : { "user.session.note" : "clientAddress", + "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "clientAddress", @@ -832,6 +2573,7 @@ "consentRequired" : false, "config" : { "user.session.note" : "clientHost", + "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "clientHost", @@ -845,6 +2587,7 @@ "consentRequired" : false, "config" : { "user.session.note" : "client_id", + "userinfo.token.claim" : "true", "id.token.claim" : "true", "access.token.claim" : "true", "claim.name" : "client_id", @@ -1007,7 +2750,7 @@ "jsonType.label" : "String" } } ], - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "groups", "email" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] } ], "clientScopes" : [ { @@ -1434,7 +3177,8 @@ "config" : { "included.client.audience" : "cellxgene", "id.token.claim" : "false", - "access.token.claim" : "true" + "access.token.claim" : "true", + "userinfo.token.claim" : "false" } } ] }, { @@ -1486,6 +3230,31 @@ "user.attribute.locality" : "locality" } } ] + }, { + "id" : "9df7e6fe-8e34-46df-9b4d-fd5ca8988803", + "name" : "groups", + "description" : "", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false", + "gui.order" : "", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "367ba3b0-6e44-4998-b5c4-b8b02d8c254c", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-group-membership-mapper", + "consentRequired" : false, + "config" : { + "full.path" : "true", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "userinfo.token.claim" : "true" + } + } ] }, { "id" : "10ab99c2-ea1d-454e-8c4e-45a3654ff256", "name" : "offline_access", @@ -1496,7 +3265,7 @@ "display.on.consent.screen" : "true" } } ], - "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr", "cellxgene-audience" ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr", "cellxgene-audience", "groups" ], "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], "browserSecurityHeaders" : { "contentSecurityPolicyReportOnly" : "", @@ -1566,7 +3335,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "saml-role-list-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "oidc-address-mapper" ] } }, { "id" : "e39a6376-abb0-4130-888d-60e85966cb6a", @@ -1575,7 +3344,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper" ] } }, { "id" : "a6543da3-77ec-4635-8bf8-6141c83f98b1", @@ -2193,4 +3962,4 @@ "clientPolicies" : { "policies" : [ ] } -} \ No newline at end of file +} ] \ No newline at end of file diff --git a/src/biodm/api.py b/src/biodm/api.py index 93bdaad..f82974a 100644 --- a/src/biodm/api.py +++ b/src/biodm/api.py @@ -55,17 +55,10 @@ def __init__(self, app: ASGIApp, server_host: str) -> None: super().__init__(app, self.dispatch) async def dispatch(self, request: Request, call_next: Callable) -> Any: - if request.state.user_info.info: - user_id = request.state.user_info.info[0] - user_groups = request.state.user_info.info[1] - else: - user_id = "anon" - user_groups = ['no_groups'] - endpoint = str(request.url).rsplit(self.server_host, maxsplit=1)[-1] body = await request.body() entry = { - 'user_username': user_id, + 'user_username': request.user.display_name, 'endpoint': endpoint, 'method': request.method, 'content': str(body) if body else "" @@ -84,7 +77,7 @@ async def dispatch(self, request: Request, call_next: Callable) -> Any: # Log timestamp = datetime.now().strftime("%I:%M%p on %B %d, %Y") History.svc.app.logger.info( - f'{timestamp}\t{user_id}\t{",".join(user_groups)}\t' + f'{timestamp}\t{request.user.display_name}\t{",".join(request.user.groups)}\t' f'{endpoint}\t-\t{request.method}' ) @@ -177,7 +170,7 @@ def __init__( # Middlewares -> Stack goes in reverse order. self.add_middleware(HistoryMiddleware, server_host=config.SERVER_HOST) self.add_middleware(AuthenticationMiddleware) - if self.scope is Scope.PROD: + if Scope.DEBUG not in self.scope: self.add_middleware(TimeoutMiddleware, timeout=config.SERVER_TIMEOUT) # CORS last (i.e. first). self.add_middleware( diff --git a/src/biodm/basics/rootcontroller.py b/src/biodm/basics/rootcontroller.py index 57d43b9..513f81e 100644 --- a/src/biodm/basics/rootcontroller.py +++ b/src/biodm/basics/rootcontroller.py @@ -3,25 +3,30 @@ from starlette.requests import Request from starlette.responses import Response, PlainTextResponse -from starlette.routing import Route +# from starlette.routing import Route from biodm import config from biodm.components.controllers import Controller -from biodm.utils.security import login_required +from biodm.utils.security import admin_required, login_required from biodm.utils.utils import json_response +from biodm.routing import Route, PublicRoute + +from biodm import tables as bt class RootController(Controller): - """Bundles Routes located at the root of the app i.e. '/'. - """ + """Bundles Routes located at the root of the app i.e. '/'.""" def routes(self, **_): return [ - Route("/live", endpoint=self.live), - Route("/login", endpoint=self.login), - Route("/syn_ack", endpoint=self.syn_ack), + PublicRoute("/live", endpoint=self.live), + PublicRoute("/login", endpoint=self.login), + PublicRoute("/syn_ack", endpoint=self.syn_ack), + PublicRoute("/schema", endpoint=self.openapi_schema), Route("/authenticated", endpoint=self.authenticated), - Route("/schema", endpoint=self.openapi_schema), - ] + ] + ( + [Route("/kc_sync", endpoint=self.keycloak_sync)] + if hasattr(self.app, 'kc') else [] + ) @staticmethod async def live(_) -> Response: @@ -103,6 +108,21 @@ async def authenticated(self, request: Request) -> Response: description: Unauthorized. """ - assert request.state.user_info.info - user_id, groups, projects = request.state.user_info.info - return PlainTextResponse(f"{user_id}, {groups}, {projects}\n") + return PlainTextResponse(f"{request.user.display_name}, {request.user.groups}\n") + + @admin_required + async def keycloak_sync(self, _) -> Response: + """Fetch in all keycloak entities. + + --- + description: Route to sync DB with keycloak entities, reserved to administrators. + responses: + 200: + description: Ok + 403: + description: Unauthorized. + + """ + # bt = bt + # TODO: implement. + return PlainTextResponse('OK') diff --git a/src/biodm/component.py b/src/biodm/component.py index dad0492..6e65729 100644 --- a/src/biodm/component.py +++ b/src/biodm/component.py @@ -85,7 +85,7 @@ async def filter( async def delete( self, pk_val: List[Any], - user_info: UserInfo | None = None, - **kwargs: Dict[str, Any] + session: AsyncSession, + user_info: UserInfo | None = None ) -> None: raise NotImplementedError diff --git a/src/biodm/components/controllers/admincontroller.py b/src/biodm/components/controllers/admincontroller.py index 349bb3a..b3b3dd7 100644 --- a/src/biodm/components/controllers/admincontroller.py +++ b/src/biodm/components/controllers/admincontroller.py @@ -1,6 +1,5 @@ from marshmallow import Schema -from biodm.components import Base from biodm.utils.security import admin_required, login_required from .resourcecontroller import ResourceController diff --git a/src/biodm/components/controllers/controller.py b/src/biodm/components/controllers/controller.py index 1c32adc..e8416ea 100644 --- a/src/biodm/components/controllers/controller.py +++ b/src/biodm/components/controllers/controller.py @@ -6,10 +6,8 @@ from io import BytesIO from typing import Any, Iterable, List, Dict, TYPE_CHECKING, Optional -from marshmallow import RAISE from marshmallow.schema import Schema from marshmallow.exceptions import ValidationError -from marshmallow.types import StrSequenceOrSet from sqlalchemy.exc import MissingGreenlet from starlette.requests import Request from starlette.responses import Response @@ -18,7 +16,7 @@ from biodm import config from biodm.component import ApiComponent from biodm.exceptions import ( - PayloadJSONDecodingError, AsyncDBError, SchemaError + DataError, PayloadJSONDecodingError, AsyncDBError, SchemaError ) from biodm.utils.utils import json_response @@ -105,6 +103,9 @@ def validate( json_data = json.loads(data) # Accepts **kwargs in case support needed. return cls.schema.load(json_data, many=many, partial=partial) + except ValidationError as ve: + raise DataError(str(ve.messages)) + except json.JSONDecodeError as e: raise PayloadJSONDecodingError(cls.__name__) from e diff --git a/src/biodm/components/controllers/resourcecontroller.py b/src/biodm/components/controllers/resourcecontroller.py index 80e967e..13eae1c 100644 --- a/src/biodm/components/controllers/resourcecontroller.py +++ b/src/biodm/components/controllers/resourcecontroller.py @@ -6,12 +6,10 @@ from types import MethodType from typing import TYPE_CHECKING, Callable, List, Set, Any, Dict, Type -# from marshmallow import ValidationError from marshmallow.schema import RAISE from marshmallow.class_registry import get_class from marshmallow.exceptions import RegistryError -from sqlalchemy.exc import IntegrityError -from starlette.routing import Mount, Route, BaseRoute +from starlette.routing import Mount, BaseRoute from starlette.requests import Request from starlette.responses import Response @@ -29,12 +27,12 @@ InvalidCollectionMethod, PayloadEmptyError, PartialIndex, - UpdateVersionedError ) from biodm.utils.security import UserInfo from biodm.utils.utils import json_response from biodm.utils.apispec import register_runtime_schema, process_apispec_docstrings from biodm.components import Base +from biodm.routing import Route, PublicRoute from .controller import HttpMethod, EntityController if TYPE_CHECKING: @@ -202,7 +200,7 @@ def routes(self, **_) -> List[Mount | Route] | List[Mount] | List[BaseRoute]: Route(f"{self.prefix}", self.create, methods=[HttpMethod.POST]), Route(f"{self.prefix}", self.filter, methods=[HttpMethod.GET]), Mount(self.prefix, routes=[ - Route('/schema', self.openapi_schema, methods=[HttpMethod.GET]), + PublicRoute('/schema', self.openapi_schema, methods=[HttpMethod.GET]), Route(f'/{self.qp_id}', self.read, methods=[HttpMethod.GET]), Route(f'/{self.qp_id}/{{attribute}}', self.read, methods=[HttpMethod.GET]), Route(f'/{self.qp_id}', self.delete, methods=[HttpMethod.DELETE]), @@ -320,23 +318,13 @@ async def create(self, request: Request) -> Response: description: Empty Payload. """ body = await self._extract_body(request) - - try: - validated_data = self.validate(body, partial=True) - created = await self.svc.write( - data=validated_data, - stmt_only=False, - user_info=request.state.user_info, - serializer=partial(self.serialize, many=isinstance(validated_data, list)) - ) - except IntegrityError as ie: - if 'UNIQUE' in ie.args[0] and 'version' in ie.args[0]: # Versioned case. - raise UpdateVersionedError( - "Attempt at updating versioned resources via POST detected" - ) - # Shall raise a Validation error, which should give more details about what's missing. - self.validate(body, partial=False) - raise # reraise primary exception if it did not. + validated_data = self.validate(body, partial=True) + created = await self.svc.write( + data=validated_data, + stmt_only=False, + user_info=request.user, + serializer=partial(self.serialize, many=isinstance(validated_data, list)) + ) return json_response(data=created, status_code=201) async def read(self, request: Request) -> Response: @@ -391,14 +379,14 @@ async def read(self, request: Request) -> Response: fields = ctrl._extract_fields( dict(request.query_params), - user_info=request.state.user_info + user_info=request.user ) return json_response( data=await self.svc.read( pk_val=self._extract_pk_val(request), fields=fields, nested_attribute=nested_attribute, - user_info=request.state.user_info, + user_info=request.user, serializer=partial(ctrl.serialize, many=many, only=fields), ), status_code=200, @@ -445,21 +433,15 @@ async def update(self, request: Request) -> Response: # Plug in pk into the dict. validated_data.update(dict(zip(self.pk, pk_val))) # type: ignore [assignment] - try: - return json_response( - data=await self.svc.write( - data=validated_data, - stmt_only=False, - user_info=request.state.user_info, - serializer=partial(self.serialize, many=isinstance(validated_data, list)), - ), - status_code=201, - ) - except IntegrityError as ie: - if 'UNIQUE' in ie.args[0] and 'version' in ie.args[0]: - raise UpdateVersionedError( - "Attempt at updating versioned resources via POST detected" - ) + return json_response( + data=await self.svc.write( + data=validated_data, + stmt_only=False, + user_info=request.user, + serializer=partial(self.serialize, many=isinstance(validated_data, list)), + ), + status_code=201, + ) async def delete(self, request: Request) -> Response: """Delete resource. @@ -483,7 +465,7 @@ async def delete(self, request: Request) -> Response: """ await self.svc.delete( pk_val=self._extract_pk_val(request), - user_info=request.state.user_info, + user_info=request.user, ) return json_response("Deleted.", status_code=200) @@ -515,12 +497,12 @@ async def filter(self, request: Request) -> Response: schema: Schema """ params = dict(request.query_params) - fields = self._extract_fields(params, user_info=request.state.user_info) + fields = self._extract_fields(params, user_info=request.user) return json_response( await self.svc.filter( fields=fields, params=params, - user_info=request.state.user_info, + user_info=request.user, serializer=partial(self.serialize, many=True, only=fields), ), status_code=200, @@ -561,14 +543,14 @@ async def release(self, request: Request) -> Response: fields = self._extract_fields( dict(request.query_params), - user_info=request.state.user_info + user_info=request.user ) return json_response( await self.svc.release( pk_val=self._extract_pk_val(request), update=validated_data, - user_info=request.state.user_info, + user_info=request.user, serializer=partial(self.serialize, many=False, only=fields), ), status_code=200 ) diff --git a/src/biodm/components/controllers/s3controller.py b/src/biodm/components/controllers/s3controller.py index 19d28dc..2069b6d 100644 --- a/src/biodm/components/controllers/s3controller.py +++ b/src/biodm/components/controllers/s3controller.py @@ -3,7 +3,7 @@ from typing import List, Type from marshmallow import Schema, RAISE -from starlette.routing import Route, Mount, BaseRoute +from starlette.routing import Mount, BaseRoute from starlette.requests import Request from starlette.responses import RedirectResponse @@ -14,6 +14,7 @@ from biodm.exceptions import ImplementionError from biodm.utils.security import UserInfo from biodm.utils.utils import json_response +from biodm.routing import PublicRoute, Route from .controller import HttpMethod from .resourcecontroller import ResourceController @@ -50,10 +51,10 @@ def routes(self, **_) -> List[Mount | Route] | List[Mount] | List[BaseRoute]: prefix = f'{self.prefix}/{self.qp_id}/' file_routes = [ Route(f'{prefix}download', self.download, methods=[HttpMethod.GET]), - Route(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]), Route(f'{prefix}complete_multipart', self.complete_multipart, methods=[HttpMethod.PUT]), + PublicRoute(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]), ] - self.post_upload_callback = Path(file_routes[1].path) + self.post_upload_callback = Path(file_routes[-1].path) return file_routes + super().routes() diff --git a/src/biodm/components/services/dbservice.py b/src/biodm/components/services/dbservice.py index aaf01cc..30b3e84 100644 --- a/src/biodm/components/services/dbservice.py +++ b/src/biodm/components/services/dbservice.py @@ -1,8 +1,10 @@ """Database service: Translates requests data into SQLA statements and execute.""" from abc import ABCMeta +from calendar import c from typing import Callable, List, Sequence, Any, Dict, overload, Literal, Type, Set from sqlalchemy import select, delete, or_, func +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import ( @@ -15,7 +17,7 @@ from biodm.component import ApiService from biodm.components import Base from biodm.exceptions import ( - DataError, EndpointError, FailedRead, FailedDelete, UpdateVersionedError, UnauthorizedError + DataError, EndpointError, FailedRead, FailedDelete, ReleaseVersionError, UpdateVersionedError, UnauthorizedError ) from biodm.managers import DatabaseManager from biodm.tables import ListGroup, Group @@ -25,7 +27,7 @@ from biodm.utils.utils import unevalled_all, unevalled_or, to_it, partition -SUPPORTED_INT_OPERATORS = ("gt", "ge", "lt", "le") +SUPPORTED_NUM_OPERATORS = ("gt", "ge", "lt", "le", "min", "max") class DatabaseService(ApiService, metaclass=ABCMeta): @@ -47,10 +49,17 @@ async def _insert( session: AsyncSession ) -> Base: """INSERT one object into the DB, check token write permissions before commit.""" - # await self._check_permissions("write", user_info, stmt_to_dict(stmt)) await self._check_permissions("write", user_info, stmt) - item = await session.scalar(stmt.to_stmt(self)) - return item + try: + item = await session.scalar(stmt.to_stmt(self)) + return item + # May occur in some cases for versioned resources. + except IntegrityError as ie: + if 'UNIQUE' in ie.args[0] and 'version' in ie.args[0]: + raise UpdateVersionedError( + "Attempt at updating versioned resources." + ) + raise @DatabaseManager.in_session async def _insert_list( @@ -181,16 +190,14 @@ async def _check_permissions( if not user_info: return - if self._login_required(verb) and not user_info.info: - raise UnauthorizedError("Authentication required.") - - groups = user_info.info[1] if user_info.info else [] + if self._login_required(verb) and not user_info.is_authenticated: + raise UnauthorizedError() # Special admin case. - if groups and 'admin' in groups: + if user_info.is_admin: return - if not self._group_required(verb, groups): + if not self._group_required(verb, user_info.groups): raise UnauthorizedError("Insufficient group privileges for this operation.") perms = self._get_permissions(verb) @@ -243,7 +250,7 @@ async def _check_permissions( # Empty perm list: public. continue - if not self._group_path_matching(set(g.path for g in allowed.groups), set(groups)): + if not self._group_path_matching(set(g.path for g in allowed.groups), set(user_info.groups)): raise UnauthorizedError(f"No {verb} access.") def _apply_read_permissions( @@ -274,10 +281,8 @@ def _apply_read_permissions( if not perms or not user_info: return stmt - groups = user_info.info[1] if user_info.info else [] - # Special admin case. - if groups and 'admin' in groups: + if user_info.is_admin: return stmt # Build nested query to filter permitted results. @@ -286,7 +291,7 @@ def _apply_read_permissions( # public. perm_stmt = select(permission['table']).where(lgverb == None) - if groups: + if user_info.groups: protected = ( select(permission['table']) .join( @@ -298,7 +303,7 @@ def _apply_read_permissions( .where( or_(*[ # Group path matching. Group.path.like(upper_level + '%') - for upper_level in groups + for upper_level in user_info.groups ]), ) ) @@ -366,12 +371,10 @@ def check_allowed_nested(self, fields: List[str], user_info: UserInfo) -> None: nested, _ = partition(fields, lambda x: x in self.table.relationships) for name in nested: target_svc = self._svc_from_rel_name(name) - if target_svc._login_required("read") and not user_info.info: - raise UnauthorizedError("Authentication required.") + if target_svc._login_required("read") and not user_info.is_authenticated: + raise UnauthorizedError() - groups = user_info.info[1] if user_info.info else [] - - if not self._group_required("read", groups): + if not self._group_required("read", user_info.groups): raise UnauthorizedError(f"Insufficient group privileges to retrieve {name}.") def takeout_unallowed_nested(self, fields: List[str], user_info: UserInfo) -> List[str]: @@ -388,12 +391,10 @@ def takeout_unallowed_nested(self, fields: List[str], user_info: UserInfo) -> Li def ncheck(name): target_svc = self._svc_from_rel_name(name) - if target_svc._login_required("read") and not user_info.info: + if target_svc._login_required("read") and not user_info.is_authenticated: return False - groups = user_info.info[1] if user_info.info else [] - - if not self._group_required("read", groups): + if not self._group_required("read", user_info.groups): return False return True @@ -446,9 +447,9 @@ def gen_upsert_holder( # submitter_username special col elif missing_data == {'submitter_username'} and self.table.has_submitter_username: - if not user_info or not user_info.info: - raise UnauthorizedError("Requires authentication.") - data['submitter_username'] = user_info.info[0] + if not user_info or not user_info.is_authenticated: + raise UnauthorizedError() + data['submitter_username'] = user_info.display_name else: raise DataError(f"{self.table.__name__} missing the following: {missing_data}.") @@ -505,7 +506,7 @@ async def write( """ # SQLite support for composite primary keys, with leading id. if ( - 'sqlite' in config.DATABASE_URL and + 'sqlite' in str(config.DATABASE_URL) and hasattr(self.table, 'id') and len(list(self.table.pk)) > 1 ): @@ -594,7 +595,7 @@ def _restrict_select_on_fields( .svc ._apply_read_permissions(user_info, rel_stmt) ) - # stmt = stmt.join(rel_stmt.subquery(), isouter=True) + stmt = stmt.join_from( self.table, rel_stmt.subquery(), @@ -650,12 +651,10 @@ async def read_nested( # Special cases for nested, as endpoint protection is not enough. target_svc = self._svc_from_rel_name(attribute) - if target_svc._login_required("read") and not user_info.info: - raise UnauthorizedError("Authentication required.") + if target_svc._login_required("read") and not user_info.is_authenticated: + raise UnauthorizedError() - groups = user_info.info[1] if user_info.info else [] - - if not target_svc._group_required("read", groups): + if not target_svc._group_required("read", user_info.groups): raise UnauthorizedError("Insufficient group privileges for this operation.") # Dynamic permissions are covered by read. @@ -680,15 +679,19 @@ def _filter_parse_num_op(self, stmt: Select, field: str, operator: str) -> Selec :return: Select statement with operator condition applied. :rtype: Select """ + col, ctype = self.table.colinfo(field) match operator.strip(')').split('('): case [("gt" | "ge" | "lt" | "le") as op, arg]: - col, ctype = self.table.colinfo(field) op_fct: Callable = getattr(col, f"__{op}__") return stmt.where(op_fct(ctype(arg))) + case [("min" | "max") as op, arg]: + op_fct: Callable = getattr(func, op) + sub = select(op_fct(col)) + return stmt.where(col == sub.scalar_subquery()) case _: raise EndpointError( f"Expecting either 'field=v1,v2' pairs or integrer" - f" operators 'field.op(v)' op in {SUPPORTED_INT_OPERATORS}") + f" operators 'field.op([v])' op in {SUPPORTED_NUM_OPERATORS}") def _filter_parse_field_cond(self, stmt: Select, field: str, values: List[str]) -> Select: """Applies field condition on a select statement. @@ -741,7 +744,8 @@ async def filter( offset = int(params.pop('start', 0)) limit = int(params.pop('end', config.LIMIT)) reverse = params.pop('reverse', None) # TODO: ? - # TODO: apply limit to nested lists as well. + + # start building statement. stmt = select(self.table) # For lower level(s) propagation. @@ -789,11 +793,19 @@ async def filter( stmt = stmt.offset(offset).limit(limit) return stmt if stmt_only else await self._select_many(stmt, **kwargs) - async def delete(self, pk_val, user_info: UserInfo | None = None, **kwargs) -> None: + @DatabaseManager.in_session + async def delete( + self, + pk_val: List[Any], + session: AsyncSession, + user_info: UserInfo | None = None + ) -> None: """DELETE.""" - # TODO: user_info ? + await self._check_permissions( + "write", user_info, dict(zip(self.pk, pk_val)), session=session + ) stmt = delete(self.table).where(self.gen_cond(pk_val)) - await self._delete(stmt, **kwargs) + await self._delete(stmt, session=session) @DatabaseManager.in_session async def release( @@ -807,9 +819,31 @@ async def release( "write", user_info, dict(zip(self.pk, pk_val)), session=session ) from copy import deepcopy + queried_version: int + # Slightly tweaked read version where we get max column instead. + stmt = select(self.table) + for i, col in enumerate(self.pk): + if col.name == 'version': + sub = select(func.max(col)).scalar_subquery() + stmt = stmt.where(col == sub) + queried_version = pk_val[i] + else: + stmt = stmt.where(col == col.type.python_type(pk_val[i])) # Get item with all columns - covers x-to-one relationships. - old_item = await self.read(pk_val, self.table.__table__.columns.keys(), session=session) + self._restrict_select_on_fields( + stmt, + fields=self.table.__table__.columns.keys(), + user_info=None + ) + old_item = await self._select(stmt, session=session) + + assert queried_version # here to suppress linters. + + if not old_item.version == queried_version: + raise ReleaseVersionError( + "Cannot release a versioned entity that has already been released." + ) # Copy and put in a `flexible` state where we may edit pk. new_item = deepcopy(old_item) diff --git a/src/biodm/components/services/kcservice.py b/src/biodm/components/services/kcservice.py index 4817b6a..07ea193 100644 --- a/src/biodm/components/services/kcservice.py +++ b/src/biodm/components/services/kcservice.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List from pathlib import Path -from biodm.exceptions import DataError +from biodm.exceptions import DataError, UnauthorizedError from biodm.managers import KeycloakManager from biodm.tables import Group, User from biodm.utils.security import UserInfo @@ -18,10 +18,16 @@ def kc(cls) -> KeycloakManager: return cls.app.kc @abstractmethod - async def update(self, remote_id: str, data: Dict[str, Any]): + async def _update(self, remote_id: str, data: Dict[str, Any]): + """Keycloak entity update method.""" raise NotImplementedError - async def sync(self, remote: Dict[str, Any], data: Dict[str, Any]): + async def sync( + self, + remote: Dict[str, Any], + data: Dict[str, Any], + user_info: UserInfo + ): """Sync Keycloak and input data.""" inter = remote.keys() & (set(c.name for c in self.table.__table__.columns) - self.table.pk) fill = { @@ -32,11 +38,20 @@ async def sync(self, remote: Dict[str, Any], data: Dict[str, Any]): if data.get(key, None) and data.get(key, None) != remote.get(key, None) } if update: - await self.update(remote['id'], update) + if not user_info.is_admin: + raise UnauthorizedError( + f"only administrators are allowed to update keycloak entities." + ) + await self._update(remote['id'], update) data.update(fill) @abstractmethod - async def read_or_create(self, data: Dict[str, Any], /) -> None: + async def read_or_create( + self, + data: Dict[str, Any], + user_info: UserInfo, + / + ) -> None: """Query entity from keycloak, create it in case it does not exists, update in case it does. Populates data with resulting id and/or found information.""" raise NotImplementedError @@ -48,22 +63,33 @@ def kcpath(path) -> Path: """Compute keycloak path from api path.""" return Path("/" + path.replace("__", "/")) - async def update(self, remote_id: str, data: Dict[str, Any]): + async def _update(self, remote_id: str, data: Dict[str, Any]): return await self.kc.update_group(group_id=remote_id, data=data) - async def read_or_create(self, data: Dict[str, Any]) -> None: + async def read_or_create( + self, + data: Dict[str, Any], + user_info: UserInfo + ) -> None: """READ group from keycloak, CREATE if missing, UPDATE if exists. :param data: Group data :type data: Dict[str, Any] + :param user_info: requesting user info + :type user_info: UserInfo """ path = self.kcpath(data['path']) group = await self.kc.get_group_by_path(str(path)) if group: - await self.sync(group, data) + await self.sync(group, data, user_info=user_info) return + if not user_info.is_admin: + raise UnauthorizedError( + f"group {path} does not exists, only administrators are allowed to create new ones." + ) + parent_id = None if not path.parent.parts == ('/',): parent = await self.kc.get_group_by_path(str(path.parent)) @@ -81,18 +107,20 @@ async def write( **kwargs ): """Create entities on Keycloak Side before passing to parent class for DB.""" - # Check permissions beforehand. - await self._check_permissions("write", user_info, data) - # Create on keycloak side for group in to_it(data): # Group first. - await self.read_or_create(group) + await self.read_or_create(group, user_info=user_info) # Then Users. for user in group.get("users", []): - await User.svc.read_or_create(user, [group["path"]], [group["id"]],) - - # Send to DB + await User.svc.read_or_create( + user, + user_info=user_info, + groups=[group["path"]], + group_ids=[group["id"]] + ) + + # Send to DB without user_info. return await super().write(data, stmt_only=stmt_only, **kwargs) async def delete(self, pk_val: List[Any], user_info: UserInfo | None = None, **_) -> None: @@ -103,12 +131,13 @@ async def delete(self, pk_val: List[Any], user_info: UserInfo | None = None, **_ class KCUserService(KCService): - async def update(self, remote_id: str, data: Dict[str, Any]): + async def _update(self, remote_id: str, data: Dict[str, Any]): return await self.kc.update_user(user_id=remote_id, data=data) async def read_or_create( self, data: Dict[str, Any], + user_info: UserInfo, groups: List[str] | None = None, group_ids: List[str] | None = None, ) -> None: @@ -116,6 +145,8 @@ async def read_or_create( :param data: Entry object representation :type data: Dict[str, Any] + :param user_info: requesting user info + :type user_info: UserInfo :param groups: User groups names, defaults to None :type groups: List[str], optional :param group_ids: User groups ids, defaults to None @@ -130,13 +161,20 @@ async def read_or_create( group_ids = group_ids or [] for gid in group_ids: await self.kc.group_user_add(user['id'], gid) - await self.sync(user, data) + await self.sync(user, data, user_info=user_info) + + elif not user_info.is_admin: + raise UnauthorizedError( + f"user {data['username']} does not exists, " + "only administrators are allowed to create new ones." + ) elif not data.get('password', None): raise DataError("Missing password in order to create User.") else: data['id'] = await self.kc.create_user(data, groups) + # Important to remove password as it is not stored locally, SQLA would throw error. data.pop('password', None) @@ -148,17 +186,23 @@ async def write( **kwargs ): """CREATE entities on Keycloak, before inserting in DB.""" - await self._check_permissions("write", user_info, data) - for user in to_it(data): # Groups first. group_paths, group_ids = [], [] for group in user.get("groups", []): - await Group.svc.read_or_create(group) + await Group.svc.read_or_create( + group, + user_info=user_info, + ) group_paths.append(group['path']) group_ids.append(group['id']) # Then User. - await self.read_or_create(user, groups=group_paths, group_ids=group_ids) + await self.read_or_create( + user, + user_info=user_info, + groups=group_paths, + group_ids=group_ids + ) return await super().write(data, stmt_only=stmt_only, **kwargs) diff --git a/src/biodm/components/table.py b/src/biodm/components/table.py index dfa2fa6..db4a2bd 100644 --- a/src/biodm/components/table.py +++ b/src/biodm/components/table.py @@ -86,7 +86,7 @@ def is_autoincrement(cls, name: str) -> bool: - https://groups.google.com/g/sqlalchemy/c/o5YQNH5UUko """ # Enforced by DatabaseService.populate_ids_sqlite - if name == 'id' and 'sqlite' in config.DATABASE_URL: + if name == 'id' and 'sqlite' in str(config.DATABASE_URL): return True if cls.__table__.columns[name] is cls.__table__.autoincrement_column: diff --git a/src/biodm/config.py b/src/biodm/config.py index f94ef0b..c733e44 100644 --- a/src/biodm/config.py +++ b/src/biodm/config.py @@ -1,10 +1,15 @@ from starlette.config import Config +from databases import DatabaseURL try: config = Config('.env') except FileNotFoundError: config = Config() +# TODO: [prio medium - before release] +# Change credentials to Secret type +# Avoids leaking them in stacktraces + # Server. API_NAME = config("API_NAME", cast=str, default="biodm_instance") API_VERSION = config("API_VERSION", cast=str, default="0.1.0") @@ -14,6 +19,7 @@ SERVER_PORT = config("SERVER_PORT", cast=int, default=8000) SECRET_KEY = config("SECRET_KEY", cast=str, default="r4nD0m_p455") SERVER_TIMEOUT = config("SERVER_TIMEOUT", cast=int, default=30) +REQUIRE_AUTH = config("REQUIRE_AUTH", cast=bool, default=False) # Responses. INDENT = config('INDENT', cast=int, default=2) diff --git a/src/biodm/error.py b/src/biodm/error.py index 0c550ba..383e3bb 100644 --- a/src/biodm/error.py +++ b/src/biodm/error.py @@ -1,8 +1,6 @@ import json from http import HTTPStatus -from marshmallow.exceptions import ValidationError - from biodm.utils.utils import json_response from .exceptions import ( EndpointError, @@ -18,7 +16,8 @@ UpdateVersionedError, FileNotUploadedError, FileTooLargeError, - DataError + DataError, + ReleaseVersionError ) @@ -46,10 +45,12 @@ async def onerror(_, exc): if issubclass(exc.__class__, RequestError): # TODO: investigate - detail = exc.detail + (str(exc.messages) if hasattr(exc, 'messages') else "") + detail = exc.detail + ( + str(exc.messages) if hasattr(exc, 'messages') else "" + ) match exc: - case ValidationError() | FileTooLargeError(): + case FileTooLargeError(): status = 400 case DataError() | EndpointError() | PayloadJSONDecodingError(): status = 400 @@ -57,7 +58,11 @@ async def onerror(_, exc): status = 404 case InvalidCollectionMethod(): status = 405 - case UpdateVersionedError() | FileNotUploadedError(): + case ( + UpdateVersionedError() | + FileNotUploadedError() | + ReleaseVersionError() + ): status = 409 case PayloadEmptyError(): status = 204 diff --git a/src/biodm/exceptions.py b/src/biodm/exceptions.py index b387537..cc41c9d 100644 --- a/src/biodm/exceptions.py +++ b/src/biodm/exceptions.py @@ -1,15 +1,15 @@ class RequestError(RuntimeError): detail: str - # orig: Exception - # , orig: Exception = Exception() + def __init__(self, detail: str) -> None: self.detail = detail - # self.orig = orig + # class DependencyError(RuntimeError): # origin: Exception # def __init__ + class DBError(RuntimeError): """Raised when DB related errors are catched.""" sa_error: Exception @@ -53,6 +53,10 @@ class UpdateVersionedError(RequestError): """Raised when an attempt at updating a versioned resource is detected.""" +class ReleaseVersionError(RequestError): + """Raised when releasing another version than the max is attempted.""" + + class FileNotUploadedError(RequestError): """Raised when trying to download a file that has not been uploaded yet.""" @@ -82,6 +86,8 @@ class PartialIndex(RequestError): class UnauthorizedError(RequestError): """Raised when a request on a group restricted route is sent by an unauthorized user.""" + def __init__(self, detail: str="Authentication required.") -> None: + super().__init__(detail) class ManifestError(RequestError): diff --git a/src/biodm/managers/dbmanager.py b/src/biodm/managers/dbmanager.py index 7f76770..dc38f1b 100644 --- a/src/biodm/managers/dbmanager.py +++ b/src/biodm/managers/dbmanager.py @@ -2,6 +2,7 @@ from contextlib import asynccontextmanager, AsyncExitStack from typing import AsyncGenerator, TYPE_CHECKING, Callable, Any +from databases import DatabaseURL from sqlalchemy import event from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker @@ -19,14 +20,14 @@ class DatabaseManager(ApiManager): """Manages DB side query execution.""" def __init__(self, app: Api) -> None: super().__init__(app=app) - self.database_url: str = self.async_database_url(config.DATABASE_URL) + self._database_url: DatabaseURL = self.async_database_url(config.DATABASE_URL) try: self.engine = create_async_engine( - self.database_url, + str(self._database_url), echo=Scope.DEBUG in app.scope, ) - if "sqlite" in self.database_url: + if "sqlite" in str(self._database_url): event.listens_for(self.engine.sync_engine, "connect")(self.sqlite_declare_strrev) self.async_session = async_sessionmaker( @@ -42,21 +43,23 @@ def endpoint(self): return f"{self.engine.url.host}:{self.engine.url.port}" @staticmethod - def async_database_url(url) -> str: + def async_database_url(url: DatabaseURL) -> str: """Adds a matching async driver to a database url.""" + url = str(url) match url.split("://"): case ["postgresql", _]: - return url.replace( # type: ignore [unreachable] + url = url.replace( # type: ignore [unreachable] "postgresql://", "postgresql+asyncpg://" ) case ["sqlite", _]: - return url.replace( # type: ignore [unreachable] + url = url.replace( # type: ignore [unreachable] "sqlite://", "sqlite+aiosqlite://" ) case _: raise DBError( "Only ['postgresql', 'sqlite'] backends are supported at the moment." ) + return DatabaseURL(url) @asynccontextmanager async def session(self) -> AsyncGenerator[AsyncSession, None]: diff --git a/src/biodm/routing.py b/src/biodm/routing.py new file mode 100644 index 0000000..e83753e --- /dev/null +++ b/src/biodm/routing.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, Sequence, Callable, Awaitable, Coroutine, Any +import starlette.routing as sr + +from starlette.requests import Request +from starlette.responses import Response +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware + + +from biodm import config +from biodm.exceptions import UnauthorizedError + + +if TYPE_CHECKING: + from biodm.utils.security import UserInfo + + +class RequireAuthMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]] + ) -> Coroutine[Any, Any, Response]: + if not request.user.is_authenticated: + raise UnauthorizedError() + return await call_next(request) + + +class PublicRoute(sr.Route): + """A route explicitely marked public. + So it is not checked for authentication even when server is run + in REQUIRE_AUTH mode.""" + + +class Route(sr.Route): + """Adds a middleware ensure user is authenticated when running server + in REQUIRE_AUTH mode.""" + def __init__( + self, + path: str, + endpoint: Callable[..., Any], + *, + methods: list[str] | None = None, + name: str | None = None, + include_in_schema: bool = True, + middleware: Sequence[Middleware] | None = None + ) -> None: + if config.REQUIRE_AUTH: + middleware = middleware or [] + middleware.append(Middleware(RequireAuthMiddleware)) + super().__init__( + path, + endpoint, + methods=methods, + name=name, + include_in_schema=include_in_schema, + middleware=middleware + ) diff --git a/src/biodm/tables/group.py b/src/biodm/tables/group.py index c67c0a5..b3359c7 100644 --- a/src/biodm/tables/group.py +++ b/src/biodm/tables/group.py @@ -41,7 +41,7 @@ def parent_path(self) -> str: @classmethod def _parent_path(cls) -> SQLColumnExpression[str]: sep = literal('__') - if "postgresql" in config.DATABASE_URL: + if "postgresql" in str(config.DATABASE_URL): return func.substring( cls.path, 0, @@ -52,7 +52,7 @@ def _parent_path(cls) -> SQLColumnExpression[str]: ) ) ) - if "sqlite" in config.DATABASE_URL: + if "sqlite" in str(config.DATABASE_URL): #  sqlite doesn't have reverse #  -> strrev declared in dbmanager #  postgres.position -> sqlite.instr diff --git a/src/biodm/utils/security.py b/src/biodm/utils/security.py index 8a2f76c..e5c56b7 100644 --- a/src/biodm/utils/security.py +++ b/src/biodm/utils/security.py @@ -4,12 +4,11 @@ from dataclasses import field as dc_field from functools import wraps from inspect import getmembers, ismethod -from typing import TYPE_CHECKING, List, Tuple, Callable, Awaitable, Set, ClassVar, Type, Any, Dict +from typing import TYPE_CHECKING, List, Tuple, Set, ClassVar, Type, Any, Dict from marshmallow import fields, Schema -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response +from starlette.requests import HTTPConnection +from starlette.types import ASGIApp, Receive, Scope, Send from sqlalchemy import ForeignKeyConstraint, Column, ForeignKey from sqlalchemy.orm import ( relationship, Relationship, backref, ONETOMANY, mapped_column, MappedColumn @@ -24,6 +23,9 @@ from biodm.managers import KeycloakManager +# TODO: [prio: not urgent] +# possible improvement, would be to rewrite the following classes using +# starlette builtins from starlette.middleware.authentication. class UserInfo(aobject): """Hold user info for a given request. @@ -32,8 +34,8 @@ class UserInfo(aobject): kc: 'KeycloakManager' _info: Tuple[str, List, List] | None = None - async def __init__(self, request: Request) -> None: # type: ignore [misc] - self.token = self.auth_header(request) + async def __init__(self, conn: HTTPConnection) -> None: # type: ignore [misc] + self.token = self.auth_header(conn) if self.token: self._info = await self.decode_token(self.token) @@ -42,10 +44,18 @@ def info(self) -> Tuple[str, List, List] | None: """info getter. Returns user_info if the request is authenticated, else None.""" return self._info + @property + def display_name(self): + return self._info[0] if self._info else "anon" + + @property + def groups(self): + return self._info[1] if self._info else ["no_groups"] + @staticmethod - def auth_header(request) -> str | None: + def auth_header(conn: HTTPConnection) -> str | None: """Check and return token from headers if present else returns None.""" - header = request.headers.get("Authorization") + header = conn.headers.get("Authorization") if not header: return None return (header.split("Bearer")[-1] if "Bearer" in header else header).strip() @@ -53,37 +63,42 @@ def auth_header(request) -> str | None: async def decode_token( self, token: str - ) -> Tuple[str, List, List]: + ) -> Tuple[str, List]: """ Decode token.""" - from biodm.tables import User - - def parse_items(token, name, default=""): - n = token.get(name, []) - return [s.replace("/", "") for s in n] if n else [default] - decoded = await self.kc.decode_token(token) - # Parse. username = decoded.get("preferred_username") - user: User = await User.svc.read(pk_val=[username], fields=['id']) groups = [ - group['path'].replace("/", "__")[2:] - for group in await self.kc.get_user_groups(user.id) + group.replace("/", "__")[2:] + for group in decoded.get('groups', []) ] or ['no_groups'] - projects = parse_items(decoded, "group_projects", "no_projects") - return username, groups, projects + return username, groups + @property + def is_authenticated(self): + return bool(self._info) + + @property + def is_admin(self): + """token bearer is admin flag""" + if not self._info: + return False + return 'admin' in self._info[1] -# pylint: disable=too-few-public-methods -class AuthenticationMiddleware(BaseHTTPMiddleware): + +class AuthenticationMiddleware: """Handle token decoding for incoming requests, populate request object with result.""" - async def dispatch( - self, - request: Request, - call_next: Callable[[Request], Awaitable[Response]] - ) -> Response: - request.state.user_info = await UserInfo(request) - return await call_next(request) + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] not in ["http", "websocket"]: + await self.app(scope, receive, send) + return + + conn = HTTPConnection(scope) + scope["user"] = await UserInfo(conn) + await self.app(scope, receive, send) def login_required(f): @@ -100,9 +115,9 @@ async def lr_write_wrapper(controller, request, *args, **kwargs): # Else hardcheck here is enough. @wraps(f) async def lr_wrapper(controller, request, *args, **kwargs): - if request.state.user_info.info: + if request.user.is_authenticated: return await f(controller, request, *args, **kwargs) - raise UnauthorizedError("Authentication required.") + raise UnauthorizedError() # Read is protected on its endpoint and is handled specifically for nested cases in codebase. if f.__name__ == "read": @@ -124,9 +139,8 @@ async def gr_write_wrapper(controller, request, *args, **kwargs): @wraps(f) async def gr_wrapper(controller, request, *args, **kwargs): - if request.state.user_info.info: - _, user_groups, _ = request.state.user_info.info - if any((ug in groups for ug in user_groups)): + if request.user.is_authenticated: # TODO: check empty group list edge case + if any((ug in groups for ug in request.user.groups)): return f(controller, request, *args, **kwargs) raise UnauthorizedError("Insufficient group privileges for this operation.") diff --git a/src/biodm/utils/sqla.py b/src/biodm/utils/sqla.py index 0f5fff2..4bf3b97 100644 --- a/src/biodm/utils/sqla.py +++ b/src/biodm/utils/sqla.py @@ -18,10 +18,10 @@ def _backend_specific_insert() -> Callable[[_DMLTableArgument], Insert]: MariaDB/InnoDB have similar constructs, in case we want to support more backends the to_stmt method from the UpsertStmtValuesHolder class below should be tweaked as well. """ - if 'postgresql' in config.DATABASE_URL.lower(): + if 'postgresql' in str(config.DATABASE_URL).lower(): return postgresql.insert - if 'sqlite' in config.DATABASE_URL.lower(): + if 'sqlite' in str(config.DATABASE_URL).lower(): return sqlite.insert raise # Should not happen. Here to suppress linters. @@ -36,11 +36,12 @@ class UpsertStmtValuesHolder(dict): Then the actual statement is emitted just before querying the DB with all possible values.""" def to_stmt(self, svc: 'DatabaseService') -> Insert | Update | Select: - """Generates an upsert (Insert + .on_conflict_do_x) depending on data population. - OR an explicit Update statement for full primary key and data - OR an explicit Select statement for full primary key and no data - the above edge cases do not necessarily always return a value, - hence we handle them that way to guarantee consistency. + """Generates an upsert (Insert + .on_conflict_do_x) depending on data population + OR an explicit Update/Select statement when the core assesses full primary key and + insufficient data to create a record. + + Latter edge cases do not necessarily always return a value, hence we handle them that way + to guarantee consistency. In case of incomplete data, some upserts will fail, and raise it up to controller which has the details about initial validation fail. diff --git a/src/example/.env b/src/example/.env index 61c310c..8925c1d 100644 --- a/src/example/.env +++ b/src/example/.env @@ -1,4 +1,6 @@ API_NAME="DWARF_BIODM_PoC" +# REQUIRE_AUTH=True + # PG_USER="postgres" # PG_PASS="pass" # PG_HOST="postgres.local:5432" diff --git a/src/example/entities/controllers/file.py b/src/example/entities/controllers/file.py index a8f4846..6c11652 100644 --- a/src/example/entities/controllers/file.py +++ b/src/example/entities/controllers/file.py @@ -4,10 +4,11 @@ from biodm.components.controllers import S3Controller, HttpMethod from biodm.exceptions import UnauthorizedError from biodm.utils.security import UserInfo +from biodm.routing import Route from starlette.requests import Request from starlette.responses import Response, PlainTextResponse #, RedirectResponse -from starlette.routing import BaseRoute, Mount, Route +from starlette.routing import BaseRoute, Mount from entities import tables @@ -44,13 +45,11 @@ async def visualize(self, request: Request) -> Response: vis_data = {'file_id': int(request.path_params.get('id'))} - user_info = await UserInfo(request) + if not request.user.is_authenticated: + raise UnauthorizedError() - if not user_info.info: - raise UnauthorizedError("Visualizing requires authentication.") + vis_data["user_username"] = request.user.display_name - vis_data["user_username"] = user_info.info[0] - - vis = await vis_svc.write(data=vis_data, stmt_only=False, user_info=user_info) + vis = await vis_svc.write(data=vis_data, stmt_only=False, user_info=request.user) return PlainTextResponse(f"http://{config.K8_HOST}/{vis.name}/") diff --git a/src/example/entities/tables/dataset.py b/src/example/entities/tables/dataset.py index f4edbc1..8dd971a 100644 --- a/src/example/entities/tables/dataset.py +++ b/src/example/entities/tables/dataset.py @@ -15,7 +15,7 @@ class Dataset(Versioned, Base): - id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in config.DATABASE_URL) + id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in str(config.DATABASE_URL)) # data fields name: Mapped[str] = mapped_column(String(50), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=True) diff --git a/src/requirements/common.txt b/src/requirements/common.txt index d7afb58..56bd7f1 100644 --- a/src/requirements/common.txt +++ b/src/requirements/common.txt @@ -3,6 +3,7 @@ apispec==6.6.1 asyncpg==0.29.0 boto3==1.34.65 botocore==1.34.65 +databases==0.9.0 marshmallow==3.20.2 python-keycloak==3.9.1 SQLAlchemy==2.0.30 diff --git a/src/tests/integration/kc/conftest.py b/src/tests/integration/kc/conftest.py index 472f3a1..215f1d9 100644 --- a/src/tests/integration/kc/conftest.py +++ b/src/tests/integration/kc/conftest.py @@ -7,6 +7,10 @@ from bs4 import BeautifulSoup +ADMIN_USERNAME = 'admin' +ADMIN_PASSWORD = '1234' + + @pytest.fixture(scope="session", autouse=True) def srv_endpoint(): key = 'API_ENDPOINT' @@ -51,6 +55,13 @@ def keycloak_login(srv_endpoint, username, password): return response.text.rstrip('\n') -@pytest.fixture +@pytest.fixture(scope="session") def utils(): return Utils + + +@pytest.fixture(scope="session") +def admin_header(srv_endpoint, utils): + """Set header for admin token bearer.""" + admin_token = utils.keycloak_login(srv_endpoint, ADMIN_USERNAME, ADMIN_PASSWORD) + return {'Authorization': f'Bearer {admin_token}'} diff --git a/src/tests/integration/kc/test_keycloak.py b/src/tests/integration/kc/test_keycloak.py index 7032396..7186615 100644 --- a/src/tests/integration/kc/test_keycloak.py +++ b/src/tests/integration/kc/test_keycloak.py @@ -1,11 +1,11 @@ +from httpx import head import requests import json import pytest - +from typing import Dict token: str = "" token_with_groups: str = "" - user_with_groups = { "username": "u_test_wg", "password": "1234", @@ -14,14 +14,16 @@ {"path": "g_test_wg2"}, ] } +user_test: Dict[str, str] = {"username": "u_test", "password": "1234", "firstName": "john", "lastName": "doe"} -user_test = {"username": "u_test", "password": "1234", "firstName": "john", "lastName": "doe"} - -tag = {"name": "xyz"} -def test_create_user(srv_endpoint, utils): +def test_create_user(srv_endpoint, utils, admin_header): """""" - response = requests.post(f'{srv_endpoint}/users', data=utils.json_bytes(user_test)) + response = requests.post( + f'{srv_endpoint}/users', + data=utils.json_bytes(user_test), + headers=admin_header + ) assert response.status_code == 201 json_response = json.loads(response.text) @@ -32,9 +34,13 @@ def test_create_user(srv_endpoint, utils): @pytest.mark.dependency(name="test_create_user") -def test_update_user(srv_endpoint, utils): +def test_update_user(srv_endpoint, utils, admin_header): update = {"username": user_test['username'], "firstName": "jack"} - response = requests.post(f'{srv_endpoint}/users', data=utils.json_bytes(update)) + response = requests.post( + f'{srv_endpoint}/users', + data=utils.json_bytes(update), + headers=admin_header + ) assert response.status_code == 201 json_response = json.loads(response.text) @@ -45,29 +51,41 @@ def test_update_user(srv_endpoint, utils): assert json_response["lastName"] == user_test["lastName"] -def test_create_user_no_passwd(srv_endpoint, utils): +def test_create_user_no_passwd(srv_endpoint, utils, admin_header): user_no_passwd = {"username": "u_no_passwd"} - response = requests.post(f'{srv_endpoint}/users', data=utils.json_bytes(user_no_passwd)) + response = requests.post( + f'{srv_endpoint}/users', + data=utils.json_bytes(user_no_passwd), + headers=admin_header + ) assert response.status_code == 400 assert "Missing password in order to create User." in response.text -def test_create_group(srv_endpoint, utils): +def test_create_group(srv_endpoint, utils, admin_header): """""" group = {"path": "g_test"} - response = requests.post(f'{srv_endpoint}/groups', data=utils.json_bytes(group)) + response = requests.post( + f'{srv_endpoint}/groups', + data=utils.json_bytes(group), + headers=admin_header + ) json_response = json.loads(response.text) assert response.status_code == 201 assert json_response["path"] == group["path"] -def test_login_user_on_keycloak_and_get_token(srv_endpoint, utils): +def test_login_user_on_keycloak_and_get_token(srv_endpoint, utils, admin_header): """""" global token # Create User user = {"username": "u_test", "password": "1234"} - response = requests.post(f'{srv_endpoint}/users', data=utils.json_bytes(user)) + response = requests.post( + f'{srv_endpoint}/users', + data=utils.json_bytes(user), + headers=admin_header + ) assert response.status_code == 201 token = utils.keycloak_login(srv_endpoint, user['username'], user['password']) @@ -86,10 +104,14 @@ def test_authenticated_endpoint(srv_endpoint): assert "['no_groups']" in response.text -def test_create_user_with_nested_group(srv_endpoint, utils): +def test_create_user_with_nested_group(srv_endpoint, utils, admin_header): """""" user = user_with_groups - response = requests.post(f'{srv_endpoint}/users', data=utils.json_bytes(user)) + response = requests.post( + f'{srv_endpoint}/users', + data=utils.json_bytes(user), + headers=admin_header + ) json_response = json.loads(response.text) wg1 = requests.get(f'{srv_endpoint}/groups/{user["groups"][0]["path"]}?fields=users') @@ -103,7 +125,7 @@ def test_create_user_with_nested_group(srv_endpoint, utils): assert any(user['username'] == wg2user['username'] for wg2user in json_wg2['users']) -def test_create_groups_with_nested_users(srv_endpoint, utils): +def test_create_groups_with_nested_users(srv_endpoint, utils, admin_header): """""" group = { "path": "g_test_wu", @@ -112,7 +134,11 @@ def test_create_groups_with_nested_users(srv_endpoint, utils): {"username": "u_test_wu2", "password": "1234"}, ] } - response = requests.post(f'{srv_endpoint}/groups', data=utils.json_bytes(group)) + response = requests.post( + f'{srv_endpoint}/groups', + data=utils.json_bytes(group), + headers=admin_header + ) json_response = json.loads(response.text) wu1 = requests.get(f'{srv_endpoint}/users/{group["users"][0]["username"]}?fields=groups') @@ -139,7 +165,7 @@ def test_login_and_authenticated_with_groups(srv_endpoint, utils): f"'{user_with_groups['groups'][1]['path']}']") in response.text -def test_create_groups_with_parent(srv_endpoint, utils): +def test_create_groups_with_parent(srv_endpoint, utils, admin_header): parent = { "path": "parent" } @@ -149,9 +175,21 @@ def test_create_groups_with_parent(srv_endpoint, utils): child2 = { "path": f"{parent['path']}__child2" } - parent_response = requests.post(f'{srv_endpoint}/groups', data=utils.json_bytes(parent)) - child_response = requests.post(f'{srv_endpoint}/groups', data=utils.json_bytes(child1)) - child2_response = requests.post(f'{srv_endpoint}/groups', data=utils.json_bytes(child2)) + parent_response = requests.post( + f'{srv_endpoint}/groups', + data=utils.json_bytes(parent), + headers=admin_header + ) + child_response = requests.post( + f'{srv_endpoint}/groups', + data=utils.json_bytes(child1), + headers=admin_header + ) + child2_response = requests.post( + f'{srv_endpoint}/groups', + data=utils.json_bytes(child2), + headers=admin_header + ) assert parent_response.status_code == 201 assert child_response.status_code == 201 @@ -172,39 +210,3 @@ def test_create_groups_with_parent(srv_endpoint, utils): assert len(json_parent['children']) == 2 assert json_parent['children'][0]['path'] == child1['path'] assert json_parent['children'][1]['path'] == child2['path'] - - -def test_create_tag_no_auth(srv_endpoint, utils): - response = requests.post(f'{srv_endpoint}/tags', data=utils.json_bytes(tag)) - - assert response.status_code == 511 - - -@pytest.mark.dependency(name="test_login_user_on_keycloak_and_get_token") -def test_create_tag_auth(srv_endpoint, utils): - headers = {'Authorization': f'Bearer {token}'} - - response = requests.post(f'{srv_endpoint}/tags', data=utils.json_bytes(tag), headers=headers) - - assert response.status_code == 201 - - json_tag = json.loads(response.text) - assert tag == json_tag - - -@pytest.mark.dependency(name="test_create_tag_auth") -def test_read_tag_no_auth(srv_endpoint): - response = requests.get(f'{srv_endpoint}/tags/{tag["name"]}') - - assert response.status_code == 511 - - -@pytest.mark.dependency(name="test_create_tag_auth") -def test_read_tag_auth(srv_endpoint, utils): - headers = {'Authorization': f'Bearer {token}'} - response = requests.get(f'{srv_endpoint}/tags/{tag["name"]}', data=utils.json_bytes(tag), headers=headers) - - assert response.status_code == 200 - - json_tag = json.loads(response.text) - assert tag == json_tag diff --git a/src/tests/integration/kc/test_permissions.py b/src/tests/integration/kc/test_permissions.py index d1db0ae..ee0821c 100644 --- a/src/tests/integration/kc/test_permissions.py +++ b/src/tests/integration/kc/test_permissions.py @@ -143,14 +143,19 @@ } -def test_create_data_and_login(srv_endpoint, utils): +tag: Dict[str, str] = {"name": "xyz"} + + +def test_create_data_and_login(srv_endpoint, utils, admin_header): global token_user1, token_user2, token_user2_child, project_2_id, project_2_read_id - groups = requests.post(f"{srv_endpoint}/groups", data=utils.json_bytes( - [ - group1, group2, group3, group2_child,# group2_grandchild - ] - )) + groups = requests.post( + f"{srv_endpoint}/groups", + data=utils.json_bytes( + [group1, group2, group3, group2_child] + ), + headers=admin_header + ) projects = requests.post(f"{srv_endpoint}/projects", data=utils.json_bytes( [ project1, project2 @@ -332,3 +337,39 @@ def test_change_project_permission(srv_endpoint, utils): json_response['perm_datasets']['read']['groups'] == project_update["perm_datasets"]["read"]["groups"] ) + + +def test_create_tag_no_auth(srv_endpoint, utils): + response = requests.post(f'{srv_endpoint}/tags', data=utils.json_bytes(tag)) + + assert response.status_code == 511 + + +@pytest.mark.dependency(name="test_create_data_and_login") +def test_create_tag_auth(srv_endpoint, utils): + headers = {'Authorization': f'Bearer {token_user1}'} + + response = requests.post(f'{srv_endpoint}/tags', data=utils.json_bytes(tag), headers=headers) + + assert response.status_code == 201 + + json_tag = json.loads(response.text) + assert tag == json_tag + + +@pytest.mark.dependency(name="test_create_tag_auth") +def test_read_tag_no_auth(srv_endpoint): + response = requests.get(f'{srv_endpoint}/tags/{tag["name"]}') + + assert response.status_code == 511 + + +@pytest.mark.dependency(name="test_create_tag_auth") +def test_read_tag_auth(srv_endpoint, utils): + headers = {'Authorization': f'Bearer {token_user1}'} + response = requests.get(f'{srv_endpoint}/tags/{tag["name"]}', data=utils.json_bytes(tag), headers=headers) + + assert response.status_code == 200 + + json_tag = json.loads(response.text) + assert tag == json_tag diff --git a/src/tests/unit/test_resource.py b/src/tests/unit/test_resource.py index 8c9ada6..a5c1a0d 100644 --- a/src/tests/unit/test_resource.py +++ b/src/tests/unit/test_resource.py @@ -131,7 +131,7 @@ def test_filter_resource_op(client): item1 = {'x': 1, 'y': 2, 'bs': [{'name': 'bip'},{'name': 'bap'},]} item2 = {'x': 3, 'y': 4, 'bs': [{'name': 'tit'},{'name': 'tat'},]} - res = client.post('/as', content=json_bytes([item1, item2])) + _ = client.post('/as', content=json_bytes([item1, item2])) response = client.get('/as?x.lt(2)') json_response = json.loads(response.text) @@ -144,6 +144,40 @@ def test_filter_resource_op(client): assert json_response['y'] == 2 +def test_filter_resource_min_max(client): + item1 = {'x': 1, 'y': 2} + item2 = {'x': 3, 'y': 4} + item3 = {'x': 5, 'y': 6} + + res_post = client.post('/as', content=json_bytes([item1, item2, item3])) + assert res_post.status_code == 201 + + res_min = client.get('/as?y.min()') + res_max = client.get('/as?x.max()') + + assert res_min.status_code == 200 + assert res_max.status_code == 200 + + json_min = next(iter(json.loads(res_min.text))) + json_max = next(iter(json.loads(res_max.text))) + + assert json_min['x'] == item1['x'] and json_min['y'] == item1['y'] + assert json_max['x'] == item3['x'] and json_max['y'] == item3['y'] + + +def test_filter_resource_min_and_cond(client): + item1 = {'x': 1, 'y': 2} + item2 = {'x': 3, 'y': 4} + item3 = {'x': 5, 'y': 6} + + res_post = client.post('/as', content=json_bytes([item1, item2, item3])) + assert res_post.status_code == 201 + + res_filter = client.get('/as?y=4&y.min()') + assert res_filter.status_code == 200 + assert json.loads(res_filter.text) == [] + + def test_filter_resource_nested(client): item1 = {'x': 1, 'y': 2, 'c': {'data': '1234'},} item2 = {'x': 3, 'y': 4, 'c': {'data': '4321'},} @@ -200,7 +234,7 @@ def test_update_unary_resource(client): cr_response = client.post('/cs', content=json_bytes(item)) item_id = json.loads(cr_response.text)['id'] - up_response = client.put(f'/cs/{item_id}', data=json_bytes({'data': 'modified'})) + up_response = client.put(f'/cs/{item_id}', content=json_bytes({'data': 'modified'})) json_response = json.loads(up_response.text) assert up_response.status_code == 201 @@ -214,7 +248,7 @@ def test_update_composite_resource(client): item_id = json.loads(cr_response.text)['id'] c_oracle = {'data': 'bop'} - up_response = client.put(f'/as/{item_id}', data=json_bytes( + up_response = client.put(f'/as/{item_id}', content=json_bytes( { 'x': 3, 'c': c_oracle diff --git a/src/tests/unit/test_versioning.py b/src/tests/unit/test_versioning.py index ceb6154..50d859d 100644 --- a/src/tests/unit/test_versioning.py +++ b/src/tests/unit/test_versioning.py @@ -144,3 +144,24 @@ def test_update_nested_list_after_release_of_parent_resource(client): release_json = json.loads(update_response.text) assert release_json['cs'] == (res_item['cs'] + oracle_nested) + + +@pytest.mark.xfail(raises=exc.ReleaseVersionError) +def test_release_twice(client): + item = {'info': 'toto', 'cs': [{'data': 'nested1'}, {'data': 'nested2'}]} + response = client.post('/ds', content=json_bytes(item)) + + assert response.status_code == 201 + res_item = json.loads(response.text) + + release_item = {'info': 'titi'} + release_response_1 = client.post( + f"/ds/{res_item['id']}_{res_item['version']}/release", + content=json_bytes(release_item) + ) + assert release_response_1.status_code == 200 + + _ = client.post( + f"/ds/{res_item['id']}_{res_item['version']}/release", + content=json_bytes(release_item) + )