aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.guix-channel53
-rw-r--r--.guix/modules/gn-auth.scm4
-rw-r--r--.pylintrc13
-rw-r--r--README.md2
-rw-r--r--gn_auth/__init__.py40
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py4
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py7
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py26
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2token.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py22
-rw-r--r--gn_auth/auth/authentication/users.py6
-rw-r--r--gn_auth/auth/authorisation/checks.py2
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py93
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py95
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py225
-rw-r--r--gn_auth/auth/authorisation/data/views.py287
-rw-r--r--gn_auth/auth/authorisation/privileges/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/base.py52
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py145
-rw-r--r--gn_auth/auth/authorisation/resources/common.py26
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py32
-rw-r--r--gn_auth/auth/authorisation/resources/models.py173
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py17
-rw-r--r--gn_auth/auth/authorisation/resources/system/views.py36
-rw-r--r--gn_auth/auth/authorisation/resources/views.py254
-rw-r--r--gn_auth/auth/authorisation/roles/models.py21
-rw-r--r--gn_auth/auth/authorisation/roles/views.py10
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py22
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py8
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py4
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py5
-rw-r--r--gn_auth/auth/authorisation/users/models.py59
-rw-r--r--gn_auth/auth/authorisation/users/views.py282
-rw-r--r--gn_auth/auth/db/sqlite3.py61
-rw-r--r--gn_auth/auth/errors.py6
-rw-r--r--gn_auth/auth/system/__init__.py5
-rw-r--r--gn_auth/auth/system/admin/resources.py71
-rw-r--r--gn_auth/auth/system/admin/users.py232
-rw-r--r--gn_auth/auth/system/admin/views.py10
-rw-r--r--gn_auth/auth/system/views.py8
-rw-r--r--gn_auth/auth/views.py6
-rw-r--r--gn_auth/debug.py22
-rw-r--r--gn_auth/errors/authlib.py2
-rw-r--r--gn_auth/hooks.py2
-rw-r--r--gn_auth/migrations/__init__.py (renamed from gn_auth/migrations.py)3
-rw-r--r--gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py (renamed from migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py)0
-rw-r--r--gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py (renamed from migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py (renamed from migrations/auth/20221108_01_CoxYh-create-the-groups-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py (renamed from migrations/auth/20221108_02_wxTr9-create-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py (renamed from migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py (renamed from migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py (renamed from migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py (renamed from migrations/auth/20221110_01_WtZ1I-create-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py (renamed from migrations/auth/20221110_05_BaNtL-create-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py (renamed from migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py (renamed from migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py (renamed from migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py (renamed from migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py (renamed from migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py (renamed from migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py (renamed from migrations/auth/20221114_03_PtWjc-create-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py (renamed from migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py (renamed from migrations/auth/20221114_05_hQun6-create-user-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py (renamed from migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py (renamed from migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py (renamed from migrations/auth/20221117_02_fmuZh-create-group-users-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py (renamed from migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py (renamed from migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py (renamed from migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py (renamed from migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py (renamed from migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py (renamed from migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py)0
-rw-r--r--gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py (renamed from migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py)0
-rw-r--r--gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py (renamed from migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py (renamed from migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py (renamed from migrations/auth/20230210_02_lDK14-create-system-admin-role.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py (renamed from migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py (renamed from migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py (renamed from migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py (renamed from migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py (renamed from migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py (renamed from migrations/auth/20230404_02_la33P-create-genotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py (renamed from migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py (renamed from migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py (renamed from migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py (renamed from migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py (renamed from migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py (renamed from migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py (renamed from migrations/auth/20230912_01_BxrhE-add-system-resource.py)0
-rw-r--r--gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py (renamed from migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py (renamed from migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py)0
-rw-r--r--gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py (renamed from migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py)0
-rw-r--r--gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py (renamed from migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py)0
-rw-r--r--gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py (renamed from migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py (renamed from migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py (renamed from migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py (renamed from migrations/auth/20240606_02_ubZri-create-resource-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py (renamed from migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py (renamed from migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py (renamed from migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py)0
-rw-r--r--gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py (renamed from migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py (renamed from migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py (renamed from migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py (renamed from migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py)0
-rw-r--r--gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py (renamed from migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py (renamed from migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py (renamed from migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py (renamed from migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py (renamed from migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py (renamed from migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py)0
-rw-r--r--gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py (renamed from migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py)0
-rw-r--r--gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py61
-rw-r--r--gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py62
-rw-r--r--gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py66
-rw-r--r--gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py49
-rw-r--r--gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py69
-rw-r--r--gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py185
-rw-r--r--gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py19
-rw-r--r--gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py62
-rw-r--r--gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py40
-rw-r--r--gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py44
-rw-r--r--gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py39
-rw-r--r--gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py79
-rw-r--r--gn_auth/migrations/auth/__init__.py (renamed from migrations/auth/__init__.py)0
-rw-r--r--gn_auth/scripts/__init__.py1
-rw-r--r--gn_auth/scripts/assign_data_to_default_admin.py (renamed from scripts/assign_data_to_default_admin.py)0
-rw-r--r--gn_auth/scripts/batch_assign_data_to_default_admin.py (renamed from scripts/batch_assign_data_to_default_admin.py)3
-rw-r--r--gn_auth/scripts/link_inbredsets.py (renamed from scripts/link_inbredsets.py)2
-rw-r--r--gn_auth/scripts/register_sys_admin.py (renamed from scripts/register_sys_admin.py)0
-rw-r--r--gn_auth/scripts/search_phenotypes.py (renamed from scripts/search_phenotypes.py)0
-rw-r--r--gn_auth/scripts/worker.py (renamed from scripts/worker.py)0
-rw-r--r--gn_auth/settings.py4
-rw-r--r--gn_auth/wsgi.py416
-rw-r--r--migrations/__init__.py1
-rw-r--r--pyproject.toml71
-rw-r--r--scripts/__init__.py0
-rw-r--r--setup.cfg4
-rwxr-xr-xsetup.py48
-rw-r--r--setup_commands/__init__.py3
-rw-r--r--setup_commands/run_tests.py40
-rw-r--r--tests/unit/auth/fixtures/group_fixtures.py21
-rw-r--r--tests/unit/auth/fixtures/resource_fixtures.py48
-rw-r--r--tests/unit/auth/fixtures/role_fixtures.py2
-rw-r--r--tests/unit/auth/fixtures/user_fixtures.py21
-rw-r--r--tests/unit/auth/test_admin_user_roles.py262
-rw-r--r--tests/unit/auth/test_admin_users.py190
-rw-r--r--tests/unit/auth/test_groups.py2
-rw-r--r--tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py2
-rw-r--r--tests/unit/auth/test_resources.py18
-rw-r--r--tests/unit/auth/test_system_admin_resources.py94
155 files changed, 3505 insertions, 1011 deletions
diff --git a/.guix-channel b/.guix-channel
index 9476e74..bfc31db 100644
--- a/.guix-channel
+++ b/.guix-channel
@@ -3,18 +3,57 @@
(directory ".guix/modules")
(dependencies
(channel
+ (name gn-machines)
+ (url "https://git.genenetwork.org/gn-machines")
+ (branch "main"))
+ ;; Until https://issues.guix.gnu.org/68797 is resolved, we need to
+ ;; explicitly list guix-bioinformatics, guix-forge, guix-past and
+ ;; guix-rust-past-crates—the dependencies of the gn-machines channel—here.
+ (channel
+ (name guix)
+ (url "https://codeberg.org/guix/guix")
+ (branch "master")
+ (commit "0a4740705090acc4c8a10d4f53afc58c9f62e980")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "9edb3f66fd807b096b48283debdcddccfea34bad")
+ (signer
+ "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))
+ (channel
+ (name guix-forge)
+ (url "https://git.systemreboot.net/guix-forge/")
+ (branch "main")
+ (commit "e43fd9a4d73654d3876e2c698af7da89f3408f89")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "0432e37b20dd678a02efee21adf0b9525a670310")
+ (signer
+ "7F73 0343 F2F0 9F3C 77BF 79D3 2E25 EE8B 6180 2BB3"))))
+ (channel
(name guix-bioinformatics)
(url "https://git.genenetwork.org/guix-bioinformatics")
- (branch "master"))
- ;; FIXME: guix-bioinformatics depends on guix-past. So, there
- ;; should be no reason to explicitly depend on guix-past. But, the
- ;; channel does not build otherwise. This is probably a guix bug.
+ (commit "9b0955f14ec725990abb1f6af3b9f171e4943f77"))
(channel
(name guix-past)
- (url "https://gitlab.inria.fr/guix-hpc/guix-past")
+ (url "https://codeberg.org/guix-science/guix-past")
+ (branch "master")
+ (commit "473c942b509ab3ead35159d27dfbf2031a36cd4d")
+ (introduction
+ (channel-introduction
+ (version 0)
+ (commit "c3bc94ee752ec545e39c1b8a29f739405767b51c")
+ (signer
+ "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5"))))
+ (channel
+ (name guix-rust-past-crates)
+ (url "https://codeberg.org/guix/guix-rust-past-crates.git")
+ (branch "trunk")
+ (commit "b8b7ffbd1cec9f56f93fae4da3a74163bbc9c570")
(introduction
(channel-introduction
(version 0)
- (commit "0c119db2ea86a389769f4d2b9c6f5c41c027e336")
+ (commit "1db24ca92c28255b28076792b93d533eabb3dc6a")
(signer
- "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5"))))))
+ "F4C2 D1DF 3FDE EA63 D1D3 0776 ACC6 6D09 CA52 8292"))))))
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm
index 0dab8d9..190f695 100644
--- a/.guix/modules/gn-auth.scm
+++ b/.guix/modules/gn-auth.scm
@@ -1,5 +1,5 @@
(define-module (gn-auth)
- #:use-module ((gn packages genenetwork)
+ #:use-module ((gn-machines genenetwork)
#:select (gn-auth) #:prefix gn:)
#:use-module ((gnu packages check) #:select (python-pylint))
#:use-module ((gnu packages python-check) #:select (python-mypy))
@@ -34,7 +34,7 @@
#~(modify-phases #$phases
(add-before 'build 'pylint
(lambda _
- (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts")))
+ (invoke "pylint" "tests" "gn_auth")))
(add-after 'pylint 'mypy
(lambda _
(invoke "mypy" ".")))))))
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 0b11d24..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,13 +0,0 @@
-[SIMILARITIES]
-
-ignore-imports=yes
-
-[MESSAGES CONTROL]
-
-disable=
- fixme,
- duplicate-code,
- no-else-return
-
-load-plugins=
- pylint.extensions.no_self_use \ No newline at end of file
diff --git a/README.md b/README.md
index 963b5c5..f6c5f04 100644
--- a/README.md
+++ b/README.md
@@ -268,7 +268,7 @@ The checks we do are
### Linting
```bash
-pylint *py tests gn_auth scripts
+pylint tests gn_auth
```
### Type-Checking
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py
index d6591e5..d03c9ef 100644
--- a/gn_auth/__init__.py
+++ b/gn_auth/__init__.py
@@ -61,33 +61,24 @@ def load_secrets_conf(app: Flask) -> None:
app.config.from_pyfile(secretsfile)
-def dev_loggers(appl: Flask) -> None:
+def dev_loggers(appl: Flask) -> logging.Logger:
"""Setup the logging handlers."""
stderr_handler = logging.StreamHandler(stream=sys.stderr)
appl.logger.addHandler(stderr_handler)
+ appl.logger.setLevel(appl.config["LOGLEVEL"])
- root_logger = logging.getLogger()
- root_logger.addHandler(stderr_handler)
- root_logger.setLevel(appl.config["LOGLEVEL"])
+ return appl.logger
-def gunicorn_loggers(appl: Flask) -> None:
+def gunicorn_loggers(appl: Flask) -> logging.Logger:
"""Use gunicorn logging handlers for the application."""
logger = logging.getLogger("gunicorn.error")
appl.logger.handlers = logger.handlers
appl.logger.setLevel(logger.level)
+ return appl.logger
-_LOGGABLE_MODULES_ = (
- "gn_auth.errors",
- "gn_auth.errors.common",
- "gn_auth.errors.authlib",
- "gn_auth.errors.http.http_4xx_errors",
- "gn_auth.errors.http.http_5xx_errors"
-)
-
-
-def setup_logging(appl: Flask) -> None:
+def setup_logging(appl: Flask, loggable_modules: tuple[str, ...] = tuple()) -> None:
"""
Setup the loggers according to the WSGI server used to run the application.
"""
@@ -96,14 +87,11 @@ def setup_logging(appl: Flask) -> None:
# https://peps.python.org/pep-3333/#id4
software, *_version_and_comments = os.environ.get(
"SERVER_SOFTWARE", "").split('/')
- if bool(software):
- gunicorn_loggers(appl)
- else:
- dev_loggers(appl)
-
- loglevel = logging.getLevelName(appl.logger.getEffectiveLevel())
- for module_logger in _LOGGABLE_MODULES_:
- logging.getLogger(module_logger).setLevel(loglevel)
+ logger = gunicorn_loggers(appl) if bool(software) else dev_loggers(appl)
+ for _logger in (
+ item for item in logger.manager.loggerDict.values()
+ if isinstance(item, logging.Logger)):
+ _logger.addFilter(lambda record: record.name in loggable_modules)
def create_app(config: Optional[dict] = None) -> Flask:
@@ -112,18 +100,18 @@ def create_app(config: Optional[dict] = None) -> Flask:
# ====== Setup configuration ======
app.config.from_object(settings) # Default settings
- # Override defaults with startup settings
- app.config.update(config or {})
# Override app settings with site-local settings
if "GN_AUTH_CONF" in os.environ:
app.config.from_envvar("GN_AUTH_CONF")
override_settings_with_envvars(app)
+ # Override defaults with startup settings
+ app.config.update(config or {})
load_secrets_conf(app)
# ====== END: Setup configuration ======
- setup_logging(app)
+ setup_logging(app, tuple(app.config.get("LOGGABLE_MODULES", [])))
check_mandatory_settings(app)
setup_oauth2_server(app)
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 200b25d..cebb3be 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -23,7 +23,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
# pylint: disable=[no-self-use]
def introspect_token(self, token: OAuth2Token) -> dict:
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 80922f1..e647214 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -1,9 +1,9 @@
"""Handle token revocation."""
from flask import current_app
+from gn_libs import sqlite3 as db
from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
save_token, OAuth2Token, revoke_token)
@@ -15,7 +15,7 @@ class RevocationEndpoint(_RevocationEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
def revoke_token(self, token: OAuth2Token, request):
"""Revoke token `token`."""
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
index 08b2a3b..82fd8e1 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
@@ -1,16 +1,14 @@
"""endpoint utilities"""
-from typing import Any, Optional
+from typing import Optional
from flask import current_app
from pymonad.maybe import Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
OAuth2Token, token_by_access_token, token_by_refresh_token)
-def query_token(# pylint: disable=[unused-argument]
- endpoint_object: Any, token_str: str, token_type_hint) -> Optional[
- OAuth2Token]:
+def query_token(token_str: str, token_type_hint) -> Optional[OAuth2Token]:
"""Retrieve the token from the database."""
def __identity__(val):
"""Identity function."""
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index c802091..63f979c 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,9 +1,8 @@
"""JWT as Authorisation Grant"""
import uuid
import time
-
+import logging
from typing import Optional
-from flask import current_app as app
from authlib.jose import jwt
from authlib.common.encoding import to_native
@@ -12,12 +11,17 @@ from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
-from gn_auth.debug import __pk__
+from gn_libs.debug import make_peeker
+
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
"""
A JSON Web Token formatted bearer token generator for jwt-bearer grant type.
@@ -149,6 +153,6 @@ class JWTBearerGrant(_JWTBearerGrant):
include_refresh_token=self.request.client.check_grant_type(
"refresh_token")
)
- app.logger.debug('Issue token %r to %r', token, self.request.client)
+ logger.debug('Issue token %r to %r', token, self.request.client)
self.save_token(token)
return 200, token, self.TOKEN_RESPONSE_HEADER
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index 71769e1..39249ba 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -3,9 +3,10 @@ import uuid
import time
from typing import Optional
+from flask import current_app as app
from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
from gn_auth.auth.authentication.oauth2.models.oauth2client import (
client as fetch_client)
@@ -19,8 +20,10 @@ class JWTBearerToken(_JWTBearerToken):
# OAuth2Client is a dataclass
super().__init__(payload, header, options, params)
self.user = with_db_connection(
- lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ app.config["AUTH_DB"],
+ lambda conn: user_by_id(conn, uuid.UUID(payload["sub"])))
self.client = with_db_connection(
+ app.config["AUTH_DB"],
lambda conn: fetch_client(
conn, uuid.UUID(payload["oauth2_client_id"])
)
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 1639e2e..818399d 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,20 +1,21 @@
"""OAuth2 Client model."""
import json
+import logging
import datetime
from uuid import UUID
+from urllib.parse import urlparse
from functools import cached_property
from dataclasses import asdict, dataclass
from typing import Any, Sequence, Optional
import requests
-from flask import current_app as app
from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
+from gn_libs.debug import make_peeker
-from gn_auth.debug import __pk__
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import (User,
fetch_users,
@@ -22,6 +23,10 @@ from gn_auth.auth.authentication.users import (User,
same_password)
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
@dataclass(frozen=True)
class OAuth2Client(ClientMixin):
"""
@@ -65,7 +70,7 @@ class OAuth2Client(ClientMixin):
jwksuri = self.client_metadata.get("public-jwks-uri")
__pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
if not bool(jwksuri):
- app.logger.debug("No Public JWKs URI set for client!")
+ logger.debug("No Public JWKs URI set for client!")
return KeySet([])
try:
## IMPORTANT: This can cause a deadlock if the client is working in
@@ -77,13 +82,12 @@ class OAuth2Client(ClientMixin):
timeout=300,
allow_redirects=True).json()["jwks"]])
except requests.ConnectionError as _connerr:
- app.logger.debug(
+ logger.debug(
"Could not connect to provided URI: %s", jwksuri, exc_info=True)
except JSONDecodeError as _jsonerr:
- app.logger.debug(
- "Could not convert response to JSON", exc_info=True)
+ logger.debug("Could not convert response to JSON", exc_info=True)
except Exception as _exc:# pylint: disable=[broad-except]
- app.logger.debug(
+ logger.debug(
"Error retrieving the JWKs for the client.", exc_info=True)
return KeySet([])
@@ -135,7 +139,9 @@ class OAuth2Client(ClientMixin):
"""
Check whether the given `redirect_uri` is one of the expected ones.
"""
- return redirect_uri in self.redirect_uris
+ uri = urlparse(redirect_uri)._replace(
+ query="")._replace(fragment="").geturl()
+ return uri in self.redirect_uris
@cached_property
def response_types(self) -> Sequence[str]:
@@ -292,6 +298,8 @@ def delete_client(
cursor.execute("DELETE FROM authorisation_code WHERE client_id=?",
params)
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
+ cursor.execute("DELETE FROM jwt_refresh_tokens WHERE client_id=?",
+ params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
index 6ec5c3d..eb13f43 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2token.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
@@ -8,8 +8,8 @@ from typing import Optional
from authlib.oauth2.rfc6749 import TokenMixin
from pymonad.tools import monad_from_none_or_value
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User, user_by_id
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 8ecf923..c09f6b6 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,4 +1,5 @@
"""Protect the resources endpoints"""
+import logging
from datetime import datetime, timezone, timedelta
from flask import current_app as app
@@ -8,14 +9,17 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
from authlib.oauth2.rfc7523 import (
JWTBearerTokenValidator as _JWTBearerTokenValidator)
from authlib.integrations.flask_oauth2 import ResourceProtector
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import list_jwks, jwks_directory
from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
JWTBearerToken)
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
token_by_access_token)
+logger = logging.getLogger(__name__)
+
+
class BearerTokenValidator(_BearerTokenValidator):
"""Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
def authenticate_token(self, token_string: str):
@@ -66,7 +70,7 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
claims.validate()
return claims
except JoseError as error:
- app.logger.debug('Authenticate token failed. %r', error)
+ logger.debug('Authenticate token failed. %r', error)
return None
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index 8ac5106..fd45b63 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -129,6 +129,7 @@ def setup_oauth2_server(app: Flask) -> None:
server.register_token_generator(
"urn:ietf:params:oauth:grant-type:jwt-bearer", jwttokengenerator)
server.register_token_generator("refresh_token", jwttokengenerator)
+ server.register_token_generator("password", jwttokengenerator)
server.register_grant(RefreshTokenGrant)
# register endpoints
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index 0e2c4eb..f73a712 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -1,5 +1,6 @@
"""Endpoints for the oauth2 server"""
import uuid
+import logging
import traceback
from urllib.parse import urlparse
@@ -15,9 +16,9 @@ from flask import (
Blueprint,
render_template,
current_app as app)
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.jwks import jwks_directory, list_jwks
from gn_auth.auth.errors import NotFoundError, ForbiddenAccess
from gn_auth.auth.authentication.users import valid_login, user_by_email
@@ -27,8 +28,10 @@ from .endpoints.revocation import RevocationEndpoint
from .endpoints.introspection import IntrospectionEndpoint
+logger = logging.getLogger(__name__)
auth = Blueprint("auth", __name__)
+
@auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"])
def delete_client(client_id: uuid.UUID):
"""Delete an OAuth2 client."""
@@ -44,7 +47,7 @@ def authorise():
or str(uuid.uuid4()))
client = server.query_client(client_id)
if not bool(client):
- flash("Invalid OAuth2 client.", "alert-danger")
+ flash("Invalid OAuth2 client.", "alert alert-danger")
if request.method == "GET":
def __forgot_password_table_exists__(conn):
@@ -65,6 +68,7 @@ def authorise():
redirect_uri=request.args["redirect_uri"],
source_uri=f"{_src.scheme}://{_src.netloc}/",
display_forgot_password=with_db_connection(
+ app.config["AUTH_DB"],
__forgot_password_table_exists__))
form = request.form
@@ -88,18 +92,18 @@ def authorise():
email=email["email"]),
code=307)
return server.create_authorization_response(request=request, grant_user=user)
- flash(email_passwd_msg, "alert-danger")
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except EmailNotValidError as _enve:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except NotFoundError as _nfe:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
- return with_db_connection(__authorise__)
+ return with_db_connection(app.config["AUTH_DB"], __authorise__)
except InvalidClientError as ice:
return render_template(
"oauth2/oauth2_error.html", error=ice), ice.status_code
diff --git a/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py
index 140ce36..5c6a430 100644
--- a/gn_auth/auth/authentication/users.py
+++ b/gn_auth/auth/authentication/users.py
@@ -1,14 +1,14 @@
"""User-specific code and data structures."""
import datetime
-from typing import Tuple
+from typing import Tuple, Union
from uuid import UUID, uuid4
from dataclasses import dataclass
import sqlite3
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
@@ -26,7 +26,7 @@ class User:
return self.user_id
@staticmethod
- def from_sqlite3_row(row: sqlite3.Row):
+ def from_sqlite3_row(row: Union[sqlite3.Row, dict]):
"""Generate a user from a row in an SQLite3 resultset"""
return User(user_id=UUID(row["user_id"]),
email=row["email"],
diff --git a/gn_auth/auth/authorisation/checks.py b/gn_auth/auth/authorisation/checks.py
index 66bb723..15d4b99 100644
--- a/gn_auth/auth/authorisation/checks.py
+++ b/gn_auth/auth/authorisation/checks.py
@@ -2,12 +2,12 @@
from functools import wraps
from typing import Callable
+from gn_libs import sqlite3 as db
from flask import request, current_app as app
from gn_auth.auth.errors import InvalidData, AuthorisationError
from . import privileges as auth_privs
-from ..db import sqlite3 as db
from ..authentication.oauth2.resource_server import require_oauth
def __system_privileges_in_roles__(conn, user): # TODO: Remove this hack.
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index ddb0add..d5af3ae 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -1,16 +1,20 @@
"""Handle linking of Genotype data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import sqlite3 as authdb
-
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve genotype data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -27,12 +31,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve genotype data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in linked_genotype_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in selected)
+ """Retrieve genotype data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["GenoFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_genotype_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"gf.Id AS GenoFreezeId, gf.Name AS dataset_name, "
@@ -41,27 +54,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN GenoFreeze AS gf "
"ON iset.InbredSetId=gf.InbredSetId ")
-
- if len(params) > 0 or bool(search_query):
- query = query + "WHERE "
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, gf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) "
+ "LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -95,3 +102,37 @@ def link_genotype_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lgd.dataset_name FROM linked_genotype_data AS lgd "
+ "INNER JOIN genotype_resources AS mr ON lgd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lgd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ params = tuple(traits_by_datasets.keys())
+ logger.debug("QUERY PARAMS: %s", params)
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 0cc644e..dd589e0 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -1,16 +1,21 @@
"""Handle linking of mRNA Assay data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import sqlite3 as authdb
-
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
+
def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve mRNA Assay data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -27,14 +32,21 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve mrna data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in linked_mrna_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in selected)
+ """Retrieve mrna data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["ProbeFreezeId"]), int(row["ProbeSetFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_mrna_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"pf.ProbeFreezeId, pf.Name AS StudyName, psf.Id AS ProbeSetFreezeId, "
@@ -43,27 +55,22 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN ProbeFreeze AS pf "
"ON iset.InbredSetId=pf.InbredSetId INNER JOIN ProbeSetFreeze AS psf "
- "ON pf.ProbeFreezeId=psf.ProbeFreezeId ") + (
- "WHERE " if (len(params) > 0 or bool(search_query)) else "")
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ "ON pf.ProbeFreezeId=psf.ProbeFreezeId ")
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', psf.ShortName) "
- "LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', "
+ "psf.ShortName) LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -100,3 +107,35 @@ def link_mrna_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(traits_by_datasets.keys()))
+ query = (
+ "SELECT r.*, rc.*, lmd.dataset_name FROM linked_mrna_data AS lmd "
+ "INNER JOIN mrna_resources AS mr ON lmd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lmd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 3e45af3..bc9a6f7 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -1,19 +1,30 @@
"""Handle linking of Phenotype data to the Auth(entic|oris)ation system."""
import uuid
+import logging
+from functools import reduce
from dataclasses import asdict
from typing import Any, Iterable
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
+from flask import request, jsonify, Response, Blueprint, current_app as app
-from gn_auth.auth.db import sqlite3 as authdb
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.errors import AuthorisationError
-from gn_auth.auth.authorisation.checks import authorised_p
+from gn_auth.auth.authorisation.resources.checks import can_delete
from gn_auth.auth.authorisation.resources.system.models import system_resource
from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
-from gn_auth.auth.authorisation.resources.checks import authorised_for2
+
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.checks import require_json
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+
+logger = logging.getLogger(__name__)
+phenosbp = Blueprint("phenotypes", __name__)
+
def linked_phenotype_data(
authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
@@ -51,41 +62,6 @@ def linked_phenotype_data(
gn3cursor.execute(query, params)
return (item for item in gn3cursor.fetchall())
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
-def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
- """Retrieve phenotype data that is not linked to any user group."""
- with gn3conn.cursor() as cursor:
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"],
- row["PublishXRefId"])
- for row in linked_phenotype_data(authconn, gn3conn))
- paramstr = ", ".join(["(?, ?, ?, ?)"] * len(params))
- query = (
- "SELECT spc.SpeciesId, spc.SpeciesName, iset.InbredSetId, "
- "iset.InbredSetName, pf.Id AS PublishFreezeId, "
- "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, "
- "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
- "FROM "
- "Species AS spc "
- "INNER JOIN InbredSet AS iset "
- "ON spc.SpeciesId=iset.SpeciesId "
- "INNER JOIN PublishFreeze AS pf "
- "ON iset.InbredSetId=pf.InbredSetId "
- "INNER JOIN PublishXRef AS pxr "
- "ON pf.InbredSetId=pxr.InbredSetId")
- if len(params) > 0:
- query = query + (
- f" WHERE (iset.InbredSetId, pf.Id, pxr.Id) NOT IN ({paramstr})")
-
- cursor.execute(query, params)
- return tuple(dict(row) for row in cursor.fetchall())
-
- return tuple()
def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
@@ -117,20 +93,22 @@ def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) ->
def link_phenotype_data(
authconn: authdb.DbConnection,
- user,
+ user: User,
group: Group,
traits: tuple[dict, ...]
) -> dict:
"""Link phenotype traits to a user group."""
- if not (authorised_for2(authconn,
- user,
- system_resource(authconn),
- ("system:data:link-to-group",))
+ if not (authorised_for_spec(
+ authconn,
+ user.user_id,
+ system_resource(authconn).resource_id,
+ "(AND system:data:link-to-group)")
or
- authorised_for2(authconn,
- user,
- group_resource(authconn, group.group_id),
- ("group:data:link-to-group",))
+ authorised_for_spec(
+ authconn,
+ user.user_id,
+ group_resource(authconn, group.group_id).resource_id,
+ "(AND group:data:link-to-group)")
):
raise AuthorisationError(
"You do not have sufficient privileges to link data to group "
@@ -155,3 +133,156 @@ def link_phenotype_data(
"group": asdict(group),
"traits": params
}
+
+
+def unlink_from_resources(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Unlink phenotypes from resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM phenotype_resources "
+ "WHERE data_link_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in data_link_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def delete_resources(
+ cursor: authdb.DbCursor,
+ resource_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Delete the specified phenotype resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM resources "
+ "WHERE resource_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in resource_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def fetch_data_link_ids(
+ cursor: authdb.DbCursor,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_ids: tuple[int, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Fetch `data_link_id` values for phenotypes."""
+ paramstr = ", ".join(["(?, ?, ?, ?)"] * len(xref_ids))
+ cursor.execute(
+ "SELECT data_link_id FROM linked_phenotype_data "
+ "WHERE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId) IN "
+ f"({paramstr})",
+ tuple(str(field) for arow in
+ ((species_id, population_id, dataset_id, xref_id)
+ for xref_id in xref_ids)
+ for field in arow))
+ return tuple(uuid.UUID(row["data_link_id"]) for row in cursor.fetchall())
+
+
+def fetch_resource_id(cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]) -> uuid.UUID:
+ """Retrieve the ID of the resource where the data is linked to.
+
+ RAISES: InvalidResourceError in the case where more the data_link_ids belong
+ to more than one resource."""
+ _paramstr = ", ".join(["?"] * len(data_link_ids))
+ cursor.execute(
+ "SELECT DISTINCT(resource_id) FROM phenotype_resources "
+ f"WHERE data_link_id IN ({_paramstr})",
+ tuple(str(_id) for _id in data_link_ids))
+ _ids = tuple(uuid.UUID(row['resource_id']) for row in cursor.fetchall())
+ if len(_ids) != 1:
+ raise AuthorisationError(
+ f"Expected data from 1 resource, got {len(_ids)} resources.")
+ return _ids[0]
+
+
+def delete_linked_data(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete the actual linked data."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM linked_phenotype_data "
+ "WHERE data_link_id=?",
+ tuple((str(_id),) for _id in data_link_ids))
+ return cursor.rowcount
+
+
+@phenosbp.route("/<int:species_id>/<int:population_id>/<int:dataset_id>/delete",
+ methods=["POST"])
+@require_json
+def delete_linked_phenotypes_data(
+ species_id: int,
+ population_id: int,
+ dataset_id: int
+) -> Response:
+ """Delete the linked phenotypes data from the database."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ authdb.connection(db_uri) as auth_conn,
+ authdb.cursor(auth_conn) as cursor):
+ _deleted = 0
+ xref_ids = tuple(request.json.get("xref_ids", []))#type: ignore[union-attr]
+ if len(xref_ids) > 0:
+ # TODO: Use background job, for huge number of xref_ids
+ data_link_ids = fetch_data_link_ids(
+ cursor, species_id, population_id, dataset_id, xref_ids)
+ resource_id = fetch_resource_id(cursor, data_link_ids)
+ # - Does user have DELETE privilege on the data
+ if not can_delete(auth_conn, _token.user.user_id, resource_id):
+ # - No: Raise `AuthorisationError` and bail!
+ raise AuthorisationError(
+ "You are not allowed to delete this resource's data.")
+ # - YES: go ahead and delete data as below.
+ _resources_ids = unlink_from_resources(cursor, data_link_ids)
+ delete_resources(cursor, _resources_ids)
+ _deleted = delete_linked_data(cursor, data_link_ids)
+
+ return jsonify({
+ # TODO: "status": "sent-to-background"/"completed"/"failed"
+ # TODO: "status-url": <status-check-uri>
+ "requested": len(xref_ids),
+ "deleted": _deleted
+ })
+
+
+def __organise_resources_data__(acc, curr) -> dict:
+ logger.debug("ORGANISING... %s", dict(curr))
+ resource_row = acc.get(curr["resource_id"], {
+ "resource_id": curr["resource_id"],
+ "resource_data": tuple(),
+ })
+ return {
+ **acc,
+ curr["resource_id"]: {
+ **resource_row,
+ "resource_data": resource_row["resource_data"] + (
+ f'{curr["dataset_name"]}::{curr["trait_id"]}',)
+ }
+ }
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ paramstr = ", ".join(["(?, ?)"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lpd.dataset_name, lpd.PublishXRefId AS trait_id "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS r ON pr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE (lpd.dataset_name, lpd.PublishXRefId) "
+ f"IN ({paramstr})")
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(
+ query, tuple(item for row in dsets_traits for item in row))
+ return tuple(reduce(
+ __organise_resources_data__,
+ cursor.fetchall(),
+ {}).values())
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 9123949..0ffc08e 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -2,9 +2,9 @@
import sys
import uuid
import json
-from dataclasses import asdict
+import logging
from typing import Any
-from functools import partial
+from functools import reduce, partial
import redis
from MySQLdb.cursors import DictCursor
@@ -13,6 +13,8 @@ from flask import request, jsonify, Response, Blueprint, current_app as app
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
@@ -21,53 +23,30 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
-from ...db import sqlite3 as db
-from ...db.sqlite3 import with_db_connection
-
from ..checks import require_json
-from ..users.models import user_resource_roles
-
-from ..resources.checks import authorised_for
-from ..resources.models import (
- user_resources, public_resources, attach_resources_data)
-
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.mrna import link_mrna_data, ungrouped_mrna_data
-from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
-from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
-
+from .mrna import (
+ link_mrna_data,
+ ungrouped_mrna_data,
+ resources_by_datasets_and_traits as mrna_resources_by_datasets_and_traits)
+from .genotypes import (
+ link_genotype_data,
+ ungrouped_genotype_data,
+ resources_by_datasets_and_traits as geno_resources_by_datasets_and_traits)
+from .phenotypes import (
+ phenosbp,
+ link_phenotype_data,
+ pheno_traits_from_db,
+ resources_by_datasets_and_traits as pheno_resources_by_datasets_and_traits)
+
+
+logger = logging.getLogger(__name__)
data = Blueprint("data", __name__)
+data.register_blueprint(phenosbp, url_prefix="/phenotypes")
-def build_trait_name(trait_fullname):
- """
- Initialises the trait's name, and other values from the search data provided
-
- This is a copy of `gn3.db.traits.build_trait_name` function.
- """
- def dataset_type(dset_name):
- if dset_name.find('Temp') >= 0:
- return "Temp"
- if dset_name.find('Geno') >= 0:
- return "Geno"
- if dset_name.find('Publish') >= 0:
- return "Publish"
- return "ProbeSet"
-
- name_parts = trait_fullname.split("::")
- assert len(name_parts) >= 2, f"Name format error: '{trait_fullname}'"
- dataset_name = name_parts[0]
- dataset_type = dataset_type(dataset_name)
- return {
- "db": {
- "dataset_name": dataset_name,
- "dataset_type": dataset_type},
- "trait_fullname": trait_fullname,
- "trait_name": name_parts[1],
- "cellid": name_parts[2] if len(name_parts) == 3 else ""
- }
@data.route("species")
def list_species() -> Response:
@@ -79,101 +58,144 @@ def list_species() -> Response:
@data.route("/authorisation", methods=["POST"])
@require_json
-def authorisation() -> Response:
+def authorisation() -> Response:# pylint: disable=[too-many-locals]
"""Retrieve the authorisation level for datasets/traits for the user."""
# Access endpoint with something like:
- # curl -X POST http://127.0.0.1:8080/api/oauth2/data/authorisation \
+ # curl -X POST http://127.0.0.1:8081/auth/data/authorisation \
# -H "Content-Type: application/json" \
# -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695",
# "BXDPublish::10001"]}'
+ def __organise_traits__(acc, curr):
+ dset, _trt = curr
+ key = "ProbeSet"
+ if dset.endswith("Publish"):
+ key = "Publish"
+ elif dset.endswith("Geno"):
+ key="Geno"
+ elif dset.endswith("Temp"):
+ key = "Temp"
+ else:
+ key = "ProbeSet"
+
+ return {
+ **acc,
+ key: acc.get(key, tuple()) + (curr,)
+ }
+ _dset_traits: dict[str, tuple[tuple[str, str], ...]] = reduce(
+ __organise_traits__,
+ (
+ (dset.strip(), trt.strip()) for dset, trt in
+ (trtstr.split("::") for trtstr in
+ request_json().get("traits", []))),
+ {key: tuple() for key in ("Publish", "ProbeSet", "Geno", "Temp")})
+
db_uri = app.config["AUTH_DB"]
- privileges = {}
user = User(uuid.uuid4(), "anon@ymous.user", "Anonymous User")
- with db.connection(db_uri) as auth_conn:
- try:
- with require_oauth.acquire("profile group resource") as _token:
- user = _token.user
- resources = attach_resources_data(
- auth_conn, user_resources(auth_conn, _token.user))
- resources_roles = user_resource_roles(auth_conn, _token.user)
- privileges = {
- resource_id: tuple(
- privilege.privilege_id
- for roles in resources_roles[resource_id]
- for privilege in roles.privileges)#("group:resource:view-resource",)
- for resource_id, is_authorised
- in authorised_for(
- auth_conn, _token.user,
- ("group:resource:view-resource",), tuple(
- resource.resource_id for resource in resources)).items()
- if is_authorised
- }
- except _HTTPException as exc:
- err_msg = json.loads(exc.body)
- if err_msg["error"] == "missing_authorization":
- resources = attach_resources_data(
- auth_conn, public_resources(auth_conn))
- else:
- raise exc from None
-
- def __gen_key__(resource, data_item):
- if resource.resource_category.resource_category_key.lower() == "phenotype":
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}::{data_item['PublishXRefId']}")
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}")
-
- data_to_resource_map = {
- __gen_key__(resource, data_item): resource.resource_id
- for resource in resources
- for data_item in resource.resource_data
+ with (db.connection(db_uri) as authconn, db.cursor(authconn) as cursor):
+ _all_resources = {
+ _rrow["resource_id"]: _rrow
+ for _rtypes in (
+ pheno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Publish"]),
+ geno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Geno"]),
+ mrna_resources_by_datasets_and_traits(
+ authconn, _dset_traits["ProbeSet"]))
+ for _rrow in _rtypes
}
- privileges = {
- **{
- resource.resource_id: ("system:resource:public-read",)
- for resource in resources if resource.public
- },
- **privileges}
-
- args = request.get_json()
- traits_names = args["traits"] # type: ignore[index]
- def __translate__(val):
+ if (len(_all_resources.keys()) == 0 and
+ len(_dset_traits.get("Temp", tuple())) == 0):
+ raise NotFoundError(
+ "No resource(s) found for specified trait(s). Do(es) the "
+ "trait(s) actually exist?")
+
+ # Handle Temp traits specially - they should be public/anonymous resources
+ if len(_dset_traits.get("Temp", tuple())) > 0:
+ # Create a synthetic public resource for Temp traits
+ # Use a predictable ID to identify synthetic temp resources
+ temp_resource_id = "gn-auth-temp-traits"
+ _all_resources[temp_resource_id] = {
+ "resource_id": temp_resource_id,
+ "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"])
+ }
+
+ _resource_ids = tuple(_all_resources.keys())
+
+
+ def __explode_resource_data__(trait_fullname):
+ _dset, _trt = trait_fullname.split("::")
return {
- "Temp": "Temp",
- "ProbeSet": "mRNA",
- "Geno": "Genotype",
- "Publish": "Phenotype"
- }[val]
-
- def __trait_key__(trait):
- dataset_type = __translate__(trait['db']['dataset_type']).lower()
- dataset_name = trait["db"]["dataset_name"]
- if dataset_type == "phenotype":
- return f"{dataset_type}::{dataset_name}::{trait['trait_name']}"
- return f"{dataset_type}::{dataset_name}"
-
- return jsonify(tuple(
- {
- "user": asdict(user),
- **{key:trait[key] for key in ("trait_fullname", "trait_name")},
- "dataset_name": trait["db"]["dataset_name"],
- "dataset_type": __translate__(trait["db"]["dataset_type"]),
- "resource_id": data_to_resource_map.get(__trait_key__(trait)),
- "privileges": privileges.get(
- data_to_resource_map.get(
- __trait_key__(trait),
- uuid.UUID("4afa415e-94cb-4189-b2c6-f9ce2b6a878d")),
- tuple()) + (
- # Temporary traits do not exist in db: Set them
- # as public-read
- ("system:resource:public-read",)
- if trait["db"]["dataset_type"] == "Temp"
- else tuple())
- } for trait in
- (build_trait_name(trait_fullname)
- for trait_fullname in traits_names)))
+ "dataset_name": _dset,
+ "dataset_type": (
+ "Phenotype" if _dset.endswith("Publish")
+ else ("Genotype" if _dset.endswith("Geno")
+ else ("Temporary" if _dset.endswith("Temp")
+ else "mRNA"))),
+ "trait_name": _trt,
+ "trait_fullname": trait_fullname
+ }
+
+ _paramstr = ", ".join(["?"] * len(_resource_ids))
+ _privileges_by_resource: dict[str, tuple[str, ...]] = {}
+
+ # Separate synthetic temp resources from real resources
+ temp_resource_id = "gn-auth-temp-traits"
+ real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id)
+
+ # Query privileges only for real resources
+ if len(real_resource_ids) > 0:
+ real_paramstr = ", ".join(["?"] * len(real_resource_ids))
+ try:
+ with require_oauth.acquire("profile group resource") as _token:
+ user = _token.user
+ cursor.execute(
+ "SELECT ur.resource_id, r.role_id, rp.privilege_id "
+ "FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "WHERE ur.user_id = ? "
+ f"AND ur.resource_id IN ({real_paramstr})",
+ (str(user.user_id),) + real_resource_ids
+ )
+ _privileges_by_resource = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr["resource_id"]: (
+ acc.get(curr["resource_id"], tuple())
+ + (curr["privilege_id"],))
+ },
+ cursor.fetchall(),
+ {})
+ except _HTTPException as exc:
+ err_msg = json.loads(exc.body)
+ if err_msg["error"] == "missing_authorization":
+ cursor.execute(
+ "SELECT rsc.resource_id "
+ "FROM resources AS rsc "
+ "WHERE rsc.public = '1' "
+ f"AND rsc.resource_id IN ({real_paramstr}) ",
+ real_resource_ids)
+ _privileges_by_resource = {
+ row["resource_id"]: ('group:resource:view-resource',)
+ for row in cursor.fetchall()
+ }
+ else:
+ raise exc from None
+
+ # Temp resources are always publicly viewable
+ if temp_resource_id in _resource_ids:
+ _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',)
+
+ return jsonify({
+ "authorisation": [{
+ **resource,
+ "resource_data": [
+ __explode_resource_data__(item)
+ for item in resource["resource_data"]],
+ "privileges": _privileges_by_resource.get(resource["resource_id"], tuple())
+ } for resource in _all_resources.values()]
+ })
+
def __search_mrna__():
query = __request_key__("query", "")
@@ -184,7 +206,7 @@ def __search_mrna__():
ungrouped_mrna_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __request_key__(key: str, default: Any = ""):
if bool(request_json()):
@@ -209,7 +231,7 @@ def __search_genotypes__():
ungrouped_genotype_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __search_phenotypes__():
# launch the external process to search for phenotypes
@@ -218,7 +240,7 @@ def __search_phenotypes__():
job_id = uuid.uuid4()
selected = __request_key__("selected_traits", [])
command =[
- sys.executable, "-m", "scripts.search_phenotypes",
+ sys.executable, "-m", "gn_auth.scripts.search_phenotypes",
__request_key__("species_name"),
__request_key__("query"),
str(job_id),
@@ -284,6 +306,7 @@ def link_genotypes() -> Response:
return link_genotype_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/mrna", methods=["POST"])
@@ -309,6 +332,7 @@ def link_mrna() -> Response:
return link_mrna_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
@@ -351,4 +375,5 @@ def link_phenotype() -> Response:
pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/privileges/models.py b/gn_auth/auth/authorisation/privileges/models.py
index 77be7c0..cd23a0c 100644
--- a/gn_auth/auth/authorisation/privileges/models.py
+++ b/gn_auth/auth/authorisation/privileges/models.py
@@ -3,8 +3,8 @@ from dataclasses import dataclass
from typing import Iterable, Optional
import sqlite3
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index 333ba0d..e4a1239 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -1,10 +1,17 @@
"""Base types for resources."""
+import logging
+import datetime
from uuid import UUID
from dataclasses import dataclass
-from typing import Any, Sequence
+from typing import Any, Sequence, Optional
import sqlite3
+from gn_auth.auth.authentication.users import User
+
+
+logger = logging.getLogger(__name__)
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -22,10 +29,49 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+ created_by: Optional[User] = None
+ created_at: datetime.datetime = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ @staticmethod
+ def from_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ resource,
+ resource_id: Optional[UUID] = None,
+ resource_name: Optional[str] = None,
+ resource_category: Optional[ResourceCategory] = None,
+ public: Optional[bool] = None,
+ resource_data: Optional[Sequence[dict[str, Any]]] = None,
+ created_by: Optional[User] = None,
+ created_at: Optional[datetime.datetime] = None
+ ):
+ """Takes a Resource object `resource` and updates the attributes specified in `kwargs`."""
+ return Resource(
+ resource_id=resource_id or resource.resource_id,
+ resource_name=resource_name or resource.resource_name,
+ resource_category=resource_category or resource.resource_category,
+ public=bool(public) or resource.public,
+ resource_data=resource_data or resource.resource_data,
+ created_by=created_by or resource.created_by,
+ created_at=created_at or resource.created_at)
def resource_from_dbrow(row: sqlite3.Row):
"""Convert an SQLite3 resultset row into a resource."""
+ try:
+ created_at = datetime.datetime.fromtimestamp(row["created_at"])
+ except IndexError as _ie:
+ created_at = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ try:
+ created_by = User.from_sqlite3_row({
+ "user_id": row["creator_user_id"],
+ "email": row["creator_email"],
+ "name": row["creator_name"],
+ "verified": row["creator_verified"],
+ "created": row["creator_created"]
+ })
+ except IndexError as _ie:
+ created_by = None
+
return Resource(
resource_id=UUID(row["resource_id"]),
resource_name=row["resource_name"],
@@ -33,4 +79,6 @@ def resource_from_dbrow(row: sqlite3.Row):
UUID(row["resource_category_id"]),
row["resource_category_key"],
row["resource_category_description"]),
- public=bool(int(row["public"])))
+ public=bool(int(row["public"])),
+ created_by=created_by,
+ created_at=created_at)
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index ce2b821..7b33fcc 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,100 +1,19 @@
"""Handle authorisation checks for resources"""
import uuid
+import logging
import warnings
-from functools import reduce
-from typing import Sequence
+import gn_libs.sqlite3 as authdb
from gn_libs.privileges import check
-from .base import Resource
+from .system.models import system_resource
-from ...db import sqlite3 as db
-from ...authentication.users import User
-from ..privileges.models import db_row_to_privilege
-
-def __organise_privileges_by_resource_id__(rows):
- def __organise__(privs, row):
- resource_id = uuid.UUID(row["resource_id"])
- return {
- **privs,
- resource_id: (row["privilege_id"],) + privs.get(
- resource_id, tuple())
- }
- return reduce(__organise__, rows, {})
-
-
-def authorised_for(conn: db.DbConnection,
- user: User,
- privileges: tuple[str, ...],
- resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]:
- """
- Check whether `user` is authorised to access `resources` according to given
- `privileges`.
- """
- warnings.warn(DeprecationWarning(
- f"The function `{__name__}.authorised_for` is deprecated. Please use "
- f"`{__name__}.authorised_for_spec`"))
- with db.cursor(conn) as cursor:
- cursor.execute(
- ("SELECT ur.*, rp.privilege_id FROM "
- "user_roles AS ur "
- "INNER JOIN roles AS r ON ur.role_id=r.role_id "
- "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
- "WHERE ur.user_id=? "
- f"AND ur.resource_id IN ({', '.join(['?']*len(resource_ids))})"
- f"AND rp.privilege_id IN ({', '.join(['?']*len(privileges))})"),
- ((str(user.user_id),) + tuple(
- str(r_id) for r_id in resource_ids) + tuple(privileges)))
- resource_privileges = __organise_privileges_by_resource_id__(
- cursor.fetchall())
- authorised = tuple(resource_id for resource_id, res_privileges
- in resource_privileges.items()
- if all(priv in res_privileges
- for priv in privileges))
- return {
- resource_id: resource_id in authorised
- for resource_id in resource_ids
- }
-
-
-def authorised_for2(
- conn: db.DbConnection,
- user: User,
- resource: Resource,
- privileges: tuple[str, ...]
-) -> bool:
- """
- Check that `user` has **ALL** the specified privileges for the resource.
- """
- warnings.warn(DeprecationWarning(
- f"The function `{__name__}.authorised_for2` is deprecated. Please use "
- f"`{__name__}.authorised_for_spec`"))
- with db.cursor(conn) as cursor:
- _query = (
- "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
- "privileges.* "
- "FROM resources INNER JOIN user_roles "
- "ON resources.resource_id=user_roles.resource_id "
- "INNER JOIN roles ON user_roles.role_id=roles.role_id "
- "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
- "INNER JOIN privileges "
- "ON role_privileges.privilege_id=privileges.privilege_id "
- "WHERE resources.resource_id=? "
- "AND user_roles.user_id=?")
- cursor.execute(
- _query,
- (str(resource.resource_id), str(user.user_id)))
- _db_privileges = tuple(
- db_row_to_privilege(row) for row in cursor.fetchall())
-
- str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
- return all((requested_privilege in str_privileges)
- for requested_privilege in privileges)
+logger = logging.getLogger(__name__)
def authorised_for_spec(
- conn: db.DbConnection,
+ conn: authdb.DbConnection,
user_id: uuid.UUID,
resource_id: uuid.UUID,
auth_spec: str
@@ -103,7 +22,7 @@ def authorised_for_spec(
Check that a user, identified with `user_id`, has a set of privileges that
satisfy the `auth_spec` for the resource identified with `resource_id`.
"""
- with db.cursor(conn) as cursor:
+ with authdb.cursor(conn) as cursor:
_query = (
"SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
"privileges.* "
@@ -120,3 +39,55 @@ def authorised_for_spec(
(str(resource_id), str(user_id)))
_privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
return check(auth_spec, _privileges)
+
+
+def can_delete(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed delete a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_delete' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_delete` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(# resource-level delete access
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:delete-resource system:resource:delete)")
+ or
+ authorised_for_spec(# system-wide delete access
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(AND system:system-wide:data:delete)"))
+
+
+def can_edit(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed edit a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_edit' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_edit` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(
+ # resource-level edit access: user has edit access to his resource.
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:edit-resource system:resource:edit)")
+ or
+ authorised_for_spec(
+ # system-wide edit access: user can edit any/all resource(s).
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(OR system:system-wide:data:edit system:resource:edit)"))
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
index fd358f1..13a0c87 100644
--- a/gn_auth/auth/authorisation/resources/common.py
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -1,7 +1,7 @@
"""Utilities common to more than one resource."""
import uuid
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
def assign_resource_owner_role(
cursor: db.DbCursor,
@@ -22,27 +22,3 @@ def assign_resource_owner_role(
"ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
params)
return params
-
-
-def grant_access_to_sysadmins(
- cursor: db.DbCursor,
- resource_id: uuid.UUID,
- system_resource_id: uuid.UUID
-):
- """Grant sysadmins access to resource identified by `resource_id`."""
- cursor.execute(
- "SELECT role_id FROM roles WHERE role_name='system-administrator'")
- sysadminroleid = cursor.fetchone()[0]
-
- cursor.execute(# Fetch sysadmin IDs.
- "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
- "ON roles.role_id=user_roles.role_id "
- "WHERE role_name='system-administrator' AND resource_id=?",
- (str(system_resource_id),))
-
- cursor.executemany(
- "INSERT INTO user_roles(user_id, role_id, resource_id) "
- "VALUES (?, ?, ?) "
- "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
- tuple((row["user_id"], sysadminroleid, str(resource_id))
- for row in cursor.fetchall()))
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index a1937ce..79bead4 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -1,5 +1,6 @@
"""Handle the management of resource/user groups."""
import json
+import datetime
from uuid import UUID, uuid4
from functools import reduce
from dataclasses import dataclass
@@ -10,16 +11,13 @@ from flask import g
from pymonad.maybe import Just, Maybe, Nothing
from pymonad.either import Left, Right, Either
from pymonad.tools import monad_from_none_or_value
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
-from gn_auth.auth.authorisation.resources.system.models import system_resource
-from gn_auth.auth.authorisation.resources.common import (
- grant_access_to_sysadmins)
from gn_auth.auth.authorisation.resources.base import (
Resource,
resource_from_dbrow)
@@ -100,8 +98,12 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"create a new group."),
oauth2_scope="profile group")
def create_group(
- conn: db.DbConnection, group_name: str, group_leader: User,
- group_description: Optional[str] = None) -> Group:
+ conn: db.DbConnection,
+ group_name: str,
+ group_leader: User,
+ group_description: Optional[str] = None,
+ creator: Optional[User] = None
+) -> Group:
"""Create a new group."""
def resource_category_by_key(
cursor: db.DbCursor, category_key: str):
@@ -134,19 +136,20 @@ def create_group(
resource_category_by_key(
cursor, "group")["resource_category_id"]
),
- "public": 0
+ "public": 0,
+ "created_by": str(
+ creator.user_id if creator else group_leader.user_id),
+ "created_at": datetime.datetime.now().timestamp()
}
cursor.execute(
"INSERT INTO resources VALUES "
- "(:resource_id, :resource_name, :resource_category_id, :public)",
+ "(:resource_id, :resource_name, :resource_category_id, :public, "
+ ":created_by, :created_at)",
_group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
_group_resource)
- grant_access_to_sysadmins(cursor,
- _group_resource_id,
- system_resource(conn).resource_id)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
assign_user_role_by_name(cursor,
@@ -366,9 +369,6 @@ def remove_user_from_group(
user,
grp_resource.resource_id,
"group-creator")
- grant_access_to_sysadmins(cursor,
- grp_resource.resource_id,
- system_resource(conn).resource_id)
@authorised_p(
@@ -428,8 +428,8 @@ gjr.status='PENDING'",
return tuple(dict(row)for row in cursor.fetchall())
raise AuthorisationError(
- "You do not have the appropriate authorisation to access the "
- "group's join requests.")
+ "You need to be the group's leader in order to access the group's join "
+ "requests.")
@authorised_p(("system:group:view-group", "system:group:edit-group"),
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index 31371fd..5762551 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -1,22 +1,26 @@
"""Handle the management of resources."""
+import logging
+from datetime import datetime
from dataclasses import asdict
from uuid import UUID, uuid4
from functools import reduce, partial
-from typing import Dict, Sequence, Optional
+from typing import Dict, Union, Sequence, Optional
+
+from flask import current_app as app
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
-from .system.models import system_resource
-from .checks import authorised_for, authorised_for_spec
+from .common import assign_resource_owner_role
+from .checks import can_edit, authorised_for_spec
from .base import Resource, ResourceCategory, resource_from_dbrow
-from .common import assign_resource_owner_role, grant_access_to_sysadmins
from .groups.models import Group, is_group_leader
from .inbredset.models import resource_data as inbredset_resource_data
from .mrna import (
@@ -36,26 +40,32 @@ from .phenotypes.models import (
unlink_data_from_resource as phenotype_unlink_data_from_resource)
+logger = logging.getLogger(__name__)
+
+
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
- conn: db.DbConnection,
+ conn: Union[db.DbConnection, db.DbCursor],
resource_name: str,
resource_category: ResourceCategory,
user: User,
group: Group,
- public: bool
+ public: bool,
+ created_at: datetime = datetime.now()
) -> Resource:
"""Create a resource item."""
- with db.cursor(conn) as cursor:
+ def __create_resource__(cursor: db.DbCursor) -> Resource:
resource = Resource(uuid4(), resource_name, resource_category, public)
cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
+ "INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?)",
(str(resource.resource_id),
resource_name,
str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
+ 1 if resource.public else 0,
+ str(user.user_id),
+ created_at.timestamp()))
# TODO: @fredmanglis,@rookie101
# 1. Move the actions below into a (the?) hooks system
# 2. Do more checks: A resource can have varying hooks depending on type
@@ -70,11 +80,15 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
"VALUES (?, ?)",
(str(group.group_id), str(resource.resource_id)))
assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
- grant_access_to_sysadmins(
- cursor, resource.resource_id, system_resource(conn).resource_id)
return resource
+ if hasattr(conn, "cursor"): # This is a connection: get its cursor.
+ with db.cursor(conn) as cursor:
+ return __create_resource__(cursor)
+ else:
+ return __create_resource__(conn)
+
def delete_resource(conn: db.DbConnection, resource_id: UUID):
"""Delete a resource."""
@@ -91,6 +105,27 @@ def delete_resource(conn: db.DbConnection, resource_id: UUID):
(str(resource_id),))
+def edit_resource(conn: db.DbConnection, resource_id: UUID, name: str) -> Resource:
+ """Edit basic resource details."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("UPDATE resources SET resource_name=? "
+ "WHERE resource_id=?",
+ (name, str(resource_id)))
+ cursor.execute(
+ "SELECT r.*, rc.* FROM resources AS r "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE r.resource_id=?",
+ (str(resource_id),))
+ _resource = resource_from_dbrow(cursor.fetchone())
+ cursor.execute(
+ "SELECT u.* FROM resources AS r INNER JOIN users AS u "
+ "ON r.created_by=u.user_id WHERE r.resource_id=?",
+ (str(resource_id),))
+ return Resource.from_resource(
+ _resource, created_by=User.from_sqlite3_row(cursor.fetchone()))
+
+
def resource_category_by_id(
conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
"""Retrieve a resource category by its ID."""
@@ -118,6 +153,18 @@ def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]:
for row in cursor.fetchall())
return tuple()
+
+def __fetch_creators__(cursor, creators_ids: tuple[str, ...]):
+ cursor.execute(
+ ("SELECT * FROM users "
+ f"WHERE user_id IN ({', '.join(['?'] * len(creators_ids))})"),
+ creators_ids)
+ return {
+ row["user_id"]: User.from_sqlite3_row(row)
+ for row in cursor.fetchall()
+ }
+
+
def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
"""List all resources marked as public"""
categories = {
@@ -125,10 +172,19 @@ def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
}
with db.cursor(conn) as cursor:
cursor.execute("SELECT * FROM resources WHERE public=1")
- results = cursor.fetchall()
+ resource_rows = tuple(cursor.fetchall())
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in resource_rows))
return tuple(
- Resource(UUID(row[0]), row[1], categories[row[2]], bool(row[3]))
- for row in results)
+ Resource(
+ UUID(row[0]),
+ row[1],
+ categories[row[2]],
+ bool(row[3]),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"]))
+ for row in resource_rows)
+
def group_leader_resources(
conn: db.DbConnection, user: User, group: Group,
@@ -148,22 +204,63 @@ def group_leader_resources(
for row in cursor.fetchall())
return tuple()
-def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
+
+def user_resources(
+ conn: db.DbConnection,
+ user: User,
+ start_at: int = 0,
+ count: int = 0,
+ text_filter: str = ""
+) -> tuple[Sequence[Resource], int]:
"""List the resources available to the user"""
- with db.cursor(conn) as cursor:
- cursor.execute(
- ("SELECT DISTINCT(r.resource_id), r.resource_name, "
- "r.resource_category_id, r.public, rc.resource_category_key, "
- "rc.resource_category_description "
+ text_filter = text_filter.strip()
+ query_template = ("SELECT %%COLUMNS%% "
"FROM user_roles AS ur "
"INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
"INNER JOIN resource_categories AS rc "
"ON r.resource_category_id=rc.resource_category_id "
- "WHERE ur.user_id=?"),
+ "WHERE ur.user_id=? %%LIKE%% %%LIMITS%%")
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%", "COUNT(DISTINCT(r.resource_id)) AS count"
+ ).replace(
+ "%%LIKE%%", ""
+ ).replace(
+ "%%LIMITS%%", ""),
(str(user.user_id),))
+ _total_records = int(cursor.fetchone()["count"])
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%",
+ "DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, r.created_by, r.created_at, "
+ "rc.resource_category_key, rc.resource_category_description"
+ ).replace(
+ "%%LIKE%%",
+ ("" if text_filter == "" else (
+ "AND (r.resource_name LIKE ? OR "
+ "rc.resource_category_key LIKE ? OR "
+ "rc.resource_category_description LIKE ? )"))
+ ).replace(
+ "%%LIMITS%%",
+ ("" if count <= 0 else f"LIMIT {count} OFFSET {start_at}")),
+ (str(user.user_id),) + (
+ tuple() if text_filter == "" else
+ tuple(f"%{text_filter}%" for _ in range(0, 3))
+ ))
rows = cursor.fetchall() or []
- return tuple(resource_from_dbrow(row) for row in rows)
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in rows))
+
+ return tuple(
+ Resource.from_resource(
+ resource_from_dbrow(row),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"])
+ ) for row in rows), _total_records
+
def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]:
@@ -236,15 +333,13 @@ def link_data_to_resource(
data_link_ids: tuple[UUID, ...]
) -> tuple[dict, ...]:
"""Link data to resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data to this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
return {# type: ignore[operator]
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
@@ -254,15 +349,13 @@ def link_data_to_resource(
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
"""Unlink data from resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
dataset_type = resource.resource_category.resource_category_key
return {
"mrna": mrna_unlink_data_from_resource,
@@ -352,9 +445,7 @@ def save_resource(
conn: db.DbConnection, user: User, resource: Resource) -> Resource:
"""Update an existing resource."""
resource_id = resource.resource_id
- authorised = authorised_for(
- conn, user, ("group:resource:edit-resource",), (resource_id,))
- if authorised[resource_id]:
+ if can_edit(conn, user.user_id, resource_id):
with db.cursor(conn) as cursor:
cursor.execute(
"UPDATE resources SET "
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 303b0ac..25089fa 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -1,9 +1,10 @@
"""Base functions and utilities for system resources."""
from uuid import UUID
from functools import reduce
-from typing import Sequence
+from typing import Union, Sequence
+
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
@@ -52,9 +53,9 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple()
-def system_resource(conn: db.DbConnection) -> Resource:
+def system_resource(conn: Union[db.DbConnection, db.DbCursor]) -> Resource:
"""Retrieve the system resource."""
- with db.cursor(conn) as cursor:
+ def __fetch_sys_resource__(cursor: db.DbCursor) -> Resource:
cursor.execute(
"SELECT resource_categories.*, resources.resource_id, "
"resources.resource_name, resources.public "
@@ -65,4 +66,10 @@ def system_resource(conn: db.DbConnection) -> Resource:
if row:
return resource_from_dbrow(row)
- raise NotFoundError("Could not find a system resource!")
+ raise NotFoundError("Could not find a system resource!")
+
+ if hasattr(conn, "cursor"): # is connection
+ with db.cursor(conn) as cursor:
+ return __fetch_sys_resource__(cursor)
+ else:
+ return __fetch_sys_resource__(conn)
diff --git a/gn_auth/auth/authorisation/resources/system/views.py b/gn_auth/auth/authorisation/resources/system/views.py
index b0d40c2..54aa086 100644
--- a/gn_auth/auth/authorisation/resources/system/views.py
+++ b/gn_auth/auth/authorisation/resources/system/views.py
@@ -1,19 +1,41 @@
"""Views relating to `System` resource(s)."""
+import logging
from dataclasses import asdict
-from flask import jsonify, Blueprint
+from flask import (request,
+ jsonify,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_libs import sqlite3 as authdb
+from gn_auth.auth.authorisation.roles.models import db_rows_to_roles
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from .models import user_roles_on_system
+logger = logging.getLogger(__name__)
system = Blueprint("system", __name__)
+
@system.route("/roles")
-def system_roles():
+def system_roles() -> Response:
"""Get the roles that a user has that act on the system."""
- with require_oauth.acquire("profile group") as the_token:
- roles = with_db_connection(
- lambda conn: user_roles_on_system(conn, the_token.user))
- return jsonify(tuple(asdict(role) for role in roles))
+ with (authdb.connection(app.config["AUTH_DB"]) as conn,
+ authdb.cursor(conn) as cursor):
+ if not bool(request.headers.get("Authorization", False)):
+ cursor.execute(
+ "SELECT r.*, p.* FROM roles AS r "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE r.role_name='public-view'")
+ roles_data = tuple(
+ asdict(role) for role in db_rows_to_roles(cursor.fetchall()))
+ else:
+ with require_oauth.acquire("profile group") as the_token:
+ roles_data = tuple(
+ asdict(role) for role in
+ user_roles_on_system(conn, the_token.user))
+
+ return make_response(jsonify(roles_data), 200)
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index a960ca3..b8c7e24 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -1,9 +1,11 @@
"""The views/routes for the resources package"""
-from uuid import UUID, uuid4
+import time
import json
+import logging
import operator
+import warnings
import sqlite3
-import time
+from uuid import UUID, uuid4
from dataclasses import asdict
from functools import reduce
@@ -11,8 +13,14 @@ from functools import reduce
from werkzeug.exceptions import BadRequest
from authlib.jose import jwt
from authlib.integrations.flask_oauth2.errors import _HTTPException
-from flask import (make_response, request, jsonify, Response,
- Blueprint, current_app as app)
+from flask import (request,
+ jsonify,
+ url_for,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
+import gn_libs.privileges.resources
from gn_auth.auth.requests import request_json
@@ -43,19 +51,26 @@ from .inbredset.views import popbp
from .genotypes.views import genobp
from .phenotypes.views import phenobp
from .errors import MissingGroupError
+from .system.models import system_resource
from .groups.models import Group, user_group
-from .checks import authorised_for, authorised_for_spec
+from .checks import can_delete, authorised_for_spec
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
- get_resource_id, delete_resource as _delete_resource)
+ get_resource_id, delete_resource as _delete_resource,
+ edit_resource as _edit_resource)
+
+from .system.views import system
+
+logger = logging.getLogger(__name__)
resources = Blueprint("resources", __name__)
resources.register_blueprint(popbp, url_prefix="/")
resources.register_blueprint(genobp, url_prefix="/")
resources.register_blueprint(phenobp, url_prefix="/")
+resources.register_blueprint(system, url_prefix="/system")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -95,8 +110,7 @@ def create_resource() -> Response:
"resources.resource_name"):
raise InconsistencyError(
"You cannot have duplicate resource names.") from sql3ie
- app.logger.debug(
- f"{type(sql3ie)=}: {sql3ie=}")
+ logger.debug("type(sql3ie)=%s: sql3ie=%s", type(sql3ie), sql3ie)
raise
@@ -114,6 +128,49 @@ def view_resource(resource_id: UUID) -> Response:
)
)
+
+@resources.route("/<uuid:resource_id>/edit", methods=["POST"])
+@require_oauth("profile group resource")
+def edit_resource(resource_id: UUID) -> Response:
+ """Update/edit basic details regarding a resource."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(db_uri) as conn):
+ def __extract_privileges__(roles: tuple[Role, ...]) -> tuple[str, ...]:
+ return tuple(
+ priv.privilege_id for role in roles
+ for priv in role.privileges)
+
+ _sys_resource = system_resource(conn)
+ _privileges = {
+ ("system_privileges"
+ if _rid == _sys_resource.resource_id
+ else "resource_privileges"): __extract_privileges__(_rroles)
+ for _rid, _rroles in user_roles_on_resources(
+ conn,
+ _token.user,
+ (resource_id, _sys_resource.resource_id)
+ ).items()
+ }
+ if not gn_libs.privileges.resources.can_edit(**_privileges):
+ return make_response(jsonify({
+ "error": "AuthorisationError",
+ "error_description": "You are not allowed to edit this resource."
+ }), 401)
+
+ name = (request_json().get("resource_name") or "").strip()
+ if bool(name):
+ return jsonify({
+ "resource": asdict(_edit_resource(conn, resource_id, name)),
+ "message": "Resource updated successfully",
+ "status": "success"
+ })
+
+ return make_response(jsonify({
+ "error_description": "Expected `resource_name` to be provided.",
+ "error": "InvalidInput"
+ }), 400)
+
def __safe_get_requests_page__(key: str = "page") -> int:
"""Get the results page if it exists or default to the first page."""
try:
@@ -198,64 +255,55 @@ def resource_users(resource_id: UUID):
"""Retrieve all users with access to the given resource."""
with require_oauth.acquire("profile group resource") as the_token:
def __the_users__(conn: db.DbConnection):
- ########## BEGIN: HACK ##########
- # This hack gets the UI to work, but needs replacing.
- # It resolves (albeit, temporarily) the bug introduced after a
- # refactor that made the system itself, and the groups into
- # resources.
- grouplevelauth = authorised_for(
- conn,
- the_token.user,
- ("group:resource:view-resource",),
- (resource_id,))
- systemlevelauth = authorised_for(
- conn,
- the_token.user,
- ("system:user:list",),
- (resource_id,))
- authorised = {
- key: (grouplevelauth.get(key, False)
- or systemlevelauth.get(key, False))
- for key in grouplevelauth.keys() | systemlevelauth.keys()
- }
- ########## END: HACK ##########
- if authorised.get(resource_id, False):
- with db.cursor(conn) as cursor:
- def __organise_users_n_roles__(users_n_roles, row):
- user_id = UUID(row["user_id"])
- user = users_n_roles.get(user_id, {}).get(
- "user", User.from_sqlite3_row(row))
- role = Role(
- UUID(row["role_id"]), row["role_name"],
- bool(int(row["user_editable"])), tuple())
- return {
- **users_n_roles,
- user_id: {
- "user": user,
- "user_group": Group(
- UUID(row["group_id"]), row["group_name"],
- json.loads(row["group_metadata"])),
- "roles": users_n_roles.get(
- user_id, {}).get("roles", tuple()) + (role,)
- }
+ if not (authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ resource_id,
+ "(AND group:resource:view-resource)")
+ or authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ system_resource(conn).resource_id,
+ "(AND system:user:list)")):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to view the resource "
+ "users.")
+ with db.cursor(conn) as cursor:
+ def __organise_users_n_roles__(users_n_roles, row):
+ user_id = UUID(row["user_id"])
+ user = users_n_roles.get(user_id, {}).get(
+ "user", User.from_sqlite3_row(row))
+ role = Role(
+ UUID(row["role_id"]), row["role_name"],
+ bool(int(row["user_editable"])), tuple())
+ return {
+ **users_n_roles,
+ user_id: {
+ "user": user,
+ "user_group": (
+ Group(UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))
+ if bool(row["group_id"]) else False) ,
+ "roles": users_n_roles.get(
+ user_id, {}).get("roles", tuple()) + (role,)
}
- cursor.execute(
- "SELECT g.*, u.*, r.* "
- "FROM groups AS g INNER JOIN group_users AS gu "
- "ON g.group_id=gu.group_id INNER JOIN users AS u "
- "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
- "ON u.user_id=ur.user_id INNER JOIN roles AS r "
- "ON ur.role_id=r.role_id "
- "WHERE ur.resource_id=?",
- (str(resource_id),))
- return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
- raise AuthorisationError(
- "You do not have sufficient privileges to view the resource "
- "users.")
+ }
+ cursor.execute(
+ "SELECT g.*, u.*, r.* "
+ "FROM groups AS g INNER JOIN group_users AS gu "
+ "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
+ "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
+ "ON u.user_id=ur.user_id INNER JOIN roles AS r "
+ "ON ur.role_id=r.role_id "
+ "WHERE ur.resource_id=?",
+ (str(resource_id),))
+ return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
results = (
{
"user": asdict(row["user"]),
- "user_group": asdict(row["user_group"]),
+ "user_group": (
+ asdict(row["user_group"]) if row["user_group"] else False),
"roles": tuple(asdict(role) for role in row["roles"])
} for row in (
user_row for user_id, user_row
@@ -266,6 +314,11 @@ def resource_users(resource_id: UUID):
@require_oauth("profile group resource role")
def assign_role_to_user(resource_id: UUID) -> Response:
"""Assign a role on the specified resource to a user."""
+ warnings.warn(
+ f"The function `{__name__}.assign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.assign_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -275,11 +328,13 @@ def assign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_email), "The user email must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
user = user_by_email(conn, user_email)
return assign_resource_user(
@@ -288,12 +343,25 @@ def assign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.assign_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
@require_oauth("profile group resource role")
def unassign_role_to_user(resource_id: UUID) -> Response:
"""Unassign a role on the specified resource from a user."""
+ warnings.warn(
+ f"The function `{__name__}.unassign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.revoke_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -303,11 +371,13 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_id), "The user id must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
return unassign_resource_user(
conn, resource, user_by_id(conn, UUID(user_id)),
@@ -315,7 +385,15 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.revoke_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
def __public_view_params__(cursor, user_id, resource_id):
ignore = (str(user_id),)
@@ -468,7 +546,7 @@ def resources_authorisation():
})
resp.status_code = 400
except Exception as _exc:#pylint: disable=[broad-except]
- app.logger.debug("Generic exception.", exc_info=True)
+ logger.debug("Generic exception.", exc_info=True)
resp = jsonify({
"status": "general-exception",
"error_description": (
@@ -506,7 +584,6 @@ def get_user_roles_on_resource(name) -> Response:
response = make_response({
# Flatten this list
"roles": roles,
- "silly": "ausah",
})
iat = int(time.time())
jose_header = {
@@ -581,12 +658,11 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
db.cursor(conn) as cursor):
_role = role_by_id(conn, role_id)
- _authorised = authorised_for(
- conn,
- _token.user,
- privileges=("resource:role:edit-role",),
- resource_ids=(resource_id,)).get(resource_id)
- if not _authorised:
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:role:edit-role)"):
raise AuthorisationError(
"You are not authorised to edit/update this role.")
@@ -685,13 +761,9 @@ def delete_resource():
form = request_json()
try:
resource_id = UUID(form.get("resource_id"))
- if not authorised_for_spec(
- conn,
- the_token.user.user_id,
- resource_id,
- "(OR group:resource:delete-resource system:resource:delete)"):
- raise AuthorisationError("You do not have the appropriate "
- "privileges to delete this resource.")
+ if not can_delete(conn, the_token.user.user_id, resource_id):
+ raise AuthorisationError(
+ "You are not allowed to delete this resource.")
data = resource_data(
conn,
@@ -709,13 +781,13 @@ def delete_resource():
"description": f"Successfully deleted resource with ID '{resource_id}'."
})
except ValueError as _verr:
- app.logger.debug("Error!", exc_info=True)
+ logger.debug("Error!", exc_info=True)
return jsonify({
"error": "ValueError",
"error-description": "An invalid identifier was provided"
}), 400
except TypeError as _terr:
- app.logger.debug("Error!", exc_info=True)
+ logger.debug("Error!", exc_info=True)
return jsonify({
"error": "TypeError",
"error-description": "An invalid identifier was provided"
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index 6faeaca..89556a6 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -4,12 +4,12 @@ from functools import reduce
from dataclasses import dataclass
from typing import Sequence, Iterable, Optional
+from gn_libs import sqlite3 as db
from pymonad.either import Left, Right, Either
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from gn_auth.auth.authorisation.resources.base import Resource
-from ...db import sqlite3 as db
from ...authentication.users import User
from ..checks import authorised_p
@@ -255,6 +255,25 @@ def assign_user_role_by_name(
})
+def unassign_user_role_by_name(
+ cursor: db.DbCursor, user: User, resource_id: UUID, role_name: str):
+ """Revoke a role from `user` on `resource_id` by the role's name."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name=:role_name",
+ {"role_name": role_name})
+ role = cursor.fetchone()
+ if role:
+ cursor.execute(
+ ("DELETE FROM user_roles "
+ "WHERE user_id=:user_id AND role_id=:role_id "
+ "AND resource_id=:resource_id"),
+ {
+ "user_id": str(user.user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ })
+
+
def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
"""Fetch a role from the database by its ID."""
with db.cursor(conn) as cursor:
diff --git a/gn_auth/auth/authorisation/roles/views.py b/gn_auth/auth/authorisation/roles/views.py
index 00def89..91292e7 100644
--- a/gn_auth/auth/authorisation/roles/views.py
+++ b/gn_auth/auth/authorisation/roles/views.py
@@ -7,7 +7,7 @@ from flask import jsonify, Response, Blueprint, current_app
from ...db import sqlite3 as db
-from .models import user_role
+from .models import role_by_id
from ...authentication.oauth2.resource_server import require_oauth
@@ -17,11 +17,7 @@ roles = Blueprint("roles", __name__)
@require_oauth("profile role")
def view_role(role_id: uuid.UUID) -> Response:
"""Retrieve a user role with id `role_id`"""
- def __error__(exc: Exception):
- raise exc
- with require_oauth.acquire("profile role") as the_token:
+ with require_oauth.acquire("profile role") as _token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- the_role = user_role(conn, the_token.user, role_id)
- return the_role.either(
- __error__, lambda a_role: jsonify((asdict(a_role[0]), str(a_role[1]))))
+ return jsonify(asdict(role_by_id(conn, role_id)))# type: ignore[arg-type]
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 3d68932..65db8cc 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -4,6 +4,8 @@ import warnings
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.users.models import create_credentialed_user
def sysadmin_role(conn: db.DbConnection) -> Role:
@@ -28,14 +30,14 @@ def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
cursor.execute(
"SELECT * FROM roles WHERE role_name='system-administrator'")
admin_role = cursor.fetchone()
- cursor.execute("SELECT resources.resource_id FROM resources")
- cursor.executemany(
+ sysresource = system_resource(cursor)
+ cursor.execute(
"INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
- tuple({
+ {
"user_id": str(user.user_id),
"role_id": admin_role["role_id"],
- "resource_id": resource_id
- } for resource_id in cursor.fetchall()))
+ "resource_id": str(sysresource.resource_id)
+ })
return user
@@ -53,3 +55,13 @@ def revoke_sysadmin_role(conn: db.DbConnection, user: User):
with db.cursor(conn) as cursor:
cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?",
(str(user.user_id), str(sysadmin_role(conn).role_id)))
+
+
+def create_verified_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str
+) -> User:
+ """Create a pre-verified credentialed user with no roles."""
+ return create_credentialed_user(conn, email, name, password, verified=True)
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 9bc1c36..62eccfd 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -1,6 +1,5 @@
"""UI for admin stuff"""
import uuid
-import json
import random
import string
from typing import Optional
@@ -240,13 +239,6 @@ def register_client():
client_secret = raw_client_secret)
-def __parse_client__(sqlite3_row) -> dict:
- """Parse the client details into python datatypes."""
- return {
- **dict(sqlite3_row),
- "client_metadata": json.loads(sqlite3_row["client_metadata"])
- }
-
@admin.route("/list-client", methods=["GET"])
@is_admin
def list_clients():
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index 63443ef..30242c2 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -72,8 +72,8 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
def parse_collection(coll: dict) -> dict:
"""Parse the collection as persisted in redis to a usable python object."""
- created = coll.get("created", coll.get("created_timestamp"))
- changed = coll.get("changed", coll.get("changed_timestamp"))
+ created = coll.get("created", coll.get("created_timestamp", ""))
+ changed = coll.get("changed", coll.get("changed_timestamp", ""))
return {
"id": UUID(coll["id"]),
"name": coll["name"],
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index f619c3d..5ed2c23 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -1,4 +1,5 @@
"""Views regarding user collections."""
+import logging
from uuid import UUID
from redis import Redis
@@ -25,8 +26,10 @@ from .models import (
REDIS_COLLECTIONS_KEY,
delete_collections as _delete_collections)
+logger = logging.getLogger(__name__)
collections = Blueprint("collections", __name__)
+
@collections.route("/list")
@require_oauth("profile user")
def list_user_collections() -> Response:
@@ -44,7 +47,7 @@ def list_anonymous_collections(anon_id: UUID) -> Response:
def __list__(conn: db.DbConnection) -> tuple:
try:
_user = user_by_id(conn, anon_id)
- current_app.logger.warning(
+ logger.warning(
"Fetch collections for authenticated user using the "
"`list_user_collections()` endpoint.")
return tuple()
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index d30bfd0..21a9627 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,5 +1,6 @@
"""Functions for acting on users."""
import uuid
+import warnings
from functools import reduce
from datetime import datetime, timedelta
@@ -8,7 +9,7 @@ from ..checks import authorised_p
from ..privileges import Privilege
from ...db import sqlite3 as db
-from ...authentication.users import User
+from ...authentication.users import User, save_user, set_user_password
def __process_age_clause__(age_desc: str) -> tuple[str, int]:
@@ -128,3 +129,59 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu
(str(user.user_id),))
return __build_resource_roles__(
(dict(row) for row in cursor.fetchall()))
+
+
+def delete_users_by_id(
+ conn: db.DbConnection,
+ user_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete users unconditionally by ID, removing all dependent data.
+
+ Unlike the HTTP endpoint, this bypasses all policy checks — users are
+ deleted regardless of their roles or group memberships. Returns the
+ number of users removed from the users table.
+ """
+ warnings.warn(
+ (f"Running dangerous function `{__name__}.delete_users_by_id`. "
+ "Do ensure that is what you actually want."),
+ category=RuntimeWarning)
+ if not user_ids:
+ return 0
+ _ids = tuple(str(uid) for uid in user_ids)
+ _paramstr = ", ".join(["?"] * len(_ids))
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"),
+ )
+ with db.cursor(conn) as cursor:
+ for table, col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids)
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids)
+ return cursor.rowcount
+
+
+def create_credentialed_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str,
+ *,
+ verified: bool = False
+) -> User:
+ """Create a user with stored password credentials.
+
+ Caller controls the verified flag — pass verified=True to bypass the
+ normal email-verification flow (e.g. admin provisioning).
+ """
+ with db.cursor(conn) as cursor:
+ user = save_user(cursor, email, name, verified=verified)
+ set_user_password(cursor, user, password)
+ return user
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 4061e07..f7ac055 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,14 +1,18 @@
"""User authorisation endpoints."""
import uuid
+import logging
import sqlite3
import secrets
import traceback
+from functools import partial
+from typing import Any, Union
from dataclasses import asdict
-from typing import Any, Sequence
from urllib.parse import urljoin
-from functools import reduce, partial
from datetime import datetime, timedelta
from email.headerregistry import Address
+
+
+import werkzeug.wrappers.response
from email_validator import validate_email, EmailNotValidError
from flask import (
flash,
@@ -16,25 +20,26 @@ from flask import (
jsonify,
url_for,
redirect,
- Response,
Blueprint,
current_app,
- render_template)
+ make_response,
+ render_template,
+ Response as _Response)
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
+from gn_libs.privileges.resources import can_assign_role
from gn_auth.smtp import send_message, build_email_message
from gn_auth.auth.requests import request_json
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authorisation.resources.system.models import system_resource
-
-from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
- assign_default_roles, user_roles as _user_roles)
+ assign_default_roles, assign_user_role_by_name, unassign_user_role_by_name,
+ user_roles as _user_roles, user_roles_on_resource)
from gn_auth.auth.authorisation.resources.groups.models import (
user_group as _user_group)
@@ -42,11 +47,12 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
- AuthorisationError,
+ ForbiddenAccess,
UserRegistrationError)
-from gn_auth.auth.authentication.users import valid_login, user_by_email
+from gn_auth.auth.authentication.users import (
+ valid_login, user_by_email, user_by_id)
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, save_user, set_user_password
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
@@ -56,10 +62,14 @@ from .models import list_users
from .masquerade.views import masq
from .collections.views import collections
+logger = logging.getLogger(__name__)
+
users = Blueprint("users", __name__)
users.register_blueprint(masq, url_prefix="/masquerade")
users.register_blueprint(collections, url_prefix="/collections")
+Response = Union[_Response, werkzeug.wrappers.response.Response]
+
@users.route("/", methods=["GET"])
@require_oauth("profile")
def user_details() -> Response:
@@ -78,6 +88,22 @@ def user_details() -> Response:
**({"group": asdict(the_group)} if the_group else {})
})
+@users.route("/<user_id>", methods=["GET"])
+def get_user(user_id: str) -> Union[Response, tuple[Response, int]]:
+ """Fetch user details by user_id."""
+ try:
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_id(conn, uuid.UUID(user_id))
+ return jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ })
+ except ValueError:
+ return jsonify({"error": "Invalid user ID format"}), 400
+ except NotFoundError:
+ return jsonify({"error": "User not found"}), 404
+
@users.route("/roles", methods=["GET"])
@require_oauth("role")
def user_roles() -> Response:
@@ -218,11 +244,11 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
raise Exception(# pylint: disable=[broad-exception-raised]
@@ -300,12 +326,21 @@ def user_group() -> Response:
@require_oauth("profile resource")
def user_resources() -> Response:
"""Retrieve the resources a user has access to."""
+ _request_params = request_json()
with require_oauth.acquire("profile resource") as the_token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- return jsonify([
- asdict(resource) for resource in
- _user_resources(conn, the_token.user)])
+ _resources, _total_records = _user_resources(
+ conn,
+ the_token.user,
+ start_at=int(_request_params.get("start", 0)),
+ count=int(_request_params.get("length", 0)),
+ text_filter=_request_params.get("text_filter", ""))
+ return jsonify({
+ "resources": [asdict(resource) for resource in _resources],
+ "total-records": _total_records,
+ "filtered-records": len(_resources)
+ })
@users.route("group/join-request", methods=["GET"])
@require_oauth("profile group")
@@ -328,8 +363,9 @@ def user_join_request_exists():
"exists": False
}
with require_oauth.acquire("profile group") as the_token:
- return jsonify(with_db_connection(partial(
- __request_exists__, user=the_token.user)))
+ return jsonify(with_db_connection(
+ current_app.config["SQL_URI"],
+ partial(__request_exists__, user=the_token.user)))
@users.route("/list", methods=["GET"])
@require_oauth("profile user")
@@ -560,163 +596,51 @@ def change_password(forgot_password_token):
return change_password_page
-def __delete_users_individually__(cursor, user_ids, tables):
- """Recovery function with dismal performance."""
- _errors = tuple()
- for _user_id in user_ids:
- for _table, _col in tables:
- try:
- cursor.execute(
- f"DELETE FROM {_table} WHERE {_col}=?",
- (str(_user_id),))
- except sqlite3.IntegrityError:
- _errors = _errors + (
- (("user_id", _user_id),
- ("reason", f"User has data in table {_table}")),)
-
- return _errors
-
-
-def __fetch_non_deletable_users__(cursor, ids_and_reasons):
- """Fetch detail for non-deletable users."""
- def __merge__(acc, curr):
- _curr = dict(curr)
- _this_dict = acc.get(
- curr["user_id"], {"reasons": tuple()})
- _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
- return {**acc, curr["user_id"]: _this_dict}
-
- _reasons_by_id = reduce(__merge__,
- (dict(row) for row in ids_and_reasons),
- {})
- _user_ids = tuple(_reasons_by_id.keys())
- _paramstr = ", ".join(["?"] * len(_user_ids))
- cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
- _user_ids)
- return tuple({
- "user": dict(row),
- "reasons": _reasons_by_id[row["user_id"]]["reasons"]
- } for row in cursor.fetchall())
-
-
-def __non_deletable_with_reason__(
- user_ids: tuple[str, ...],
- dbrows: Sequence[sqlite3.Row],
- reason: str
- ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
- """Build a list of 'non-deletable' user objects."""
- return tuple((("user_id", _uid), ("reason", reason))
- for _uid in user_ids
- if _uid in tuple(row["user_id"] for row in dbrows))
-
-
-@users.route("/delete", methods=["POST"])
-@require_oauth("profile user role")
-def delete_users():
- """Delete the specified user."""
- with (require_oauth.acquire("profile") as _token,
- db.connection(current_app.config["AUTH_DB"]) as conn,
- db.cursor(conn) as cursor):
- if not authorised_for2(conn,
- _token.user,
- system_resource(conn),
- ("system:user:delete-user",)):
- raise AuthorisationError(
- "You need the `system:user:delete-user` privilege to delete "
- "users from the system.")
-
- _form = request_json()
- _user_ids = _form.get("user_ids", [])
- _non_deletable = set()
- if str(_token.user.user_id) in _user_ids:
- _non_deletable.add(
- (("user_id", str(_token.user.user_id),),
- ("reason", "You are not allowed to delete yourself.")))
-
- cursor.execute("SELECT user_id FROM group_users")
- _group_members = tuple(row["user_id"] for row in cursor.fetchall())
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- "User is member of a user group."))
-
- cursor.execute("SELECT user_id FROM oauth2_clients;")
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- "User is registered owner of an OAuth client."))
-
- _important_roles = (
- "group-leader",
- "resource-owner",
- "system-administrator",
- "inbredset-group-owner")
- _paramstr = ",".join(["?"] * len(_important_roles))
- cursor.execute(
- "SELECT DISTINCT user_roles.user_id FROM user_roles "
- "INNER JOIN roles ON user_roles.role_id=roles.role_id "
- f"WHERE roles.role_name IN ({_paramstr})",
- _important_roles)
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- f"User holds on of the following roles: {_important_roles}"))
-
- _delete = tuple(uid for uid in _user_ids if uid not in
- (dict(row)["user_id"] for row in _non_deletable))
- _paramstr = ", ".join(["?"] * len(_delete))
- if len(_delete) > 0:
- _dependent_tables = (
- ("authorisation_code", "user_id"),
- ("forgot_password_tokens", "user_id"),
- ("group_join_requests", "requester_id"),
- ("jwt_refresh_tokens", "user_id"),
- ("oauth2_tokens", "user_id"),
- ("user_credentials", "user_id"),
- ("user_roles", "user_id"),
- ("user_verification_codes", "user_id"))
- try:
- for _table, _col in _dependent_tables:
- cursor.execute(
- f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
- _delete)
- except sqlite3.IntegrityError:
- _non_deletable.update(__delete_users_individually__(
- cursor, _delete, _dependent_tables))
-
- _not_deleted = __fetch_non_deletable_users__(
- cursor, _non_deletable)
- _delete = tuple(# rebuild with those that failed.
- _user_id for _user_id in _delete if _user_id not in
- tuple(row["user"]["user_id"] for row in _not_deleted))
- _paramstr = ", ".join(["?"] * len(_delete))
- cursor.execute(
- f"DELETE FROM users WHERE user_id IN ({_paramstr})",
- _delete)
- _deleted_rows = cursor.rowcount
- return jsonify({
- "total-requested": len(_user_ids),
- "total-deleted": _deleted_rows,
- "not-deleted": _not_deleted,
- "deleted": _deleted_rows,
- "message": (
- f"Successfully deleted {_deleted_rows} users." +
- (" Some users could not be deleted."
- if len(_user_ids) - _deleted_rows > 0
- else ""))
- })
-
- _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
-
- return jsonify({
- "total-requested": len(_user_ids),
- "total-deleted": 0,
- "not-deleted": _not_deleted,
- "deleted": 0,
- "error": "Zero users were deleted",
- "error_description": (
- "No users were selected for deletion."
- if len(_user_ids) == 0
- else ("The selected users are system administrators, group "
- "members, or resource owners."))
- }), 400
+@users.route("/<uuid:user_id>/roles/assign", methods=["POST"])
+def assign_user_role(user_id: uuid.UUID) -> Response:
+ """Assign a role to a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
+
+
+@users.route("/<uuid:user_id>/roles/revoke", methods=["POST"])
+def revoke_user_role(user_id: uuid.UUID) -> Response:
+ """Revoke a role from a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
diff --git a/gn_auth/auth/db/sqlite3.py b/gn_auth/auth/db/sqlite3.py
index 12a46c7..5f54752 100644
--- a/gn_auth/auth/db/sqlite3.py
+++ b/gn_auth/auth/db/sqlite3.py
@@ -1,63 +1,28 @@
"""Handle connection to auth database."""
-import sqlite3
-import logging
-import contextlib
-from typing import Any, Protocol, Callable, Iterator
-
-import traceback
+import warnings
+from typing import Any, Callable
from flask import current_app
-from .protocols import DbCursor
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self) -> Any:
- """A cursor object"""
-
- def commit(self) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
+from gn_libs.sqlite3 import cursor, connection # pylint: disable=[unused-import]
+from gn_libs.protocols import DbCursor, DbConnection # pylint: disable=[unused-import]
-@contextlib.contextmanager
-def connection(db_path: str, row_factory: Callable = sqlite3.Row) -> Iterator[DbConnection]:
- """Create the connection to the auth database."""
- logging.debug("SQLite3 DB Path: '%s'.", db_path)
- conn = sqlite3.connect(db_path)
- conn.row_factory = row_factory
- conn.set_trace_callback(logging.debug)
- conn.execute("PRAGMA foreign_keys = ON")
- try:
- yield conn
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- conn.commit()
- conn.close()
+warnings.warn(
+ f"Module '{__name__}' is deprecated. Use `gn_libs.sqlite3` instead.",
+ category=DeprecationWarning,
+ stacklevel=2)
-@contextlib.contextmanager
-def cursor(conn: DbConnection) -> Iterator[DbCursor]:
- """Get a cursor from the given connection to the auth database."""
- cur = conn.cursor()
- try:
- yield cur
- conn.commit()
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- cur.close()
def with_db_connection(func: Callable[[DbConnection], Any]) -> Any:
"""
Takes a function of one argument `func`, whose one argument is a database
connection.
"""
+ warnings.warn(
+ (f"Function '{__name__}.with_db_connection' is deprecated. "
+ "Use `gn_libs.sqlite3.with_db_connection` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
db_uri = current_app.config["AUTH_DB"]
with connection(db_uri) as conn:
return func(conn)
diff --git a/gn_auth/auth/errors.py b/gn_auth/auth/errors.py
index 77b73aa..832d1bd 100644
--- a/gn_auth/auth/errors.py
+++ b/gn_auth/auth/errors.py
@@ -6,7 +6,7 @@ class AuthorisationError(Exception):
All exceptions in this package should inherit from this class.
"""
- error_code: int = 400
+ error_code: int = 401
class ForbiddenAccess(AuthorisationError):
"""Raised for forbidden access."""
@@ -14,6 +14,7 @@ class ForbiddenAccess(AuthorisationError):
class UserRegistrationError(AuthorisationError):
"""Raised whenever a user registration fails"""
+ error_code: int = 400
class UserVerificationError(UserRegistrationError):
"""Raised when verification of a user fails."""
@@ -26,6 +27,7 @@ class InvalidData(AuthorisationError):
"""
Exception if user requests invalid data
"""
+ error_code: int = 400
class InconsistencyError(AuthorisationError):
"""
@@ -37,8 +39,10 @@ class PasswordError(AuthorisationError):
"""
Raise in case of an error with passwords.
"""
+ error_code: int = 400
class UsernameError(AuthorisationError):
"""
Raise in case of an error with a user's name.
"""
+ error_code: int = 400
diff --git a/gn_auth/auth/system/__init__.py b/gn_auth/auth/system/__init__.py
new file mode 100644
index 0000000..5455f73
--- /dev/null
+++ b/gn_auth/auth/system/__init__.py
@@ -0,0 +1,5 @@
+"""This is for system-specific functionality, e.g. administration.
+
+This is not meant for day-to-day user activities, more for administrative tasks
+to fix things.
+"""
diff --git a/gn_auth/auth/system/admin/resources.py b/gn_auth/auth/system/admin/resources.py
new file mode 100644
index 0000000..59d7686
--- /dev/null
+++ b/gn_auth/auth/system/admin/resources.py
@@ -0,0 +1,71 @@
+"""Administrative endpoints concerning resources."""
+from uuid import UUID
+
+from flask import jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.roles.models import (
+ user_roles_on_resource,
+ assign_user_role_by_name,
+ unassign_user_role_by_name)
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+
+resources = Blueprint("resources", __name__)
+
+
+def _require_assign_owner_privilege(conn, user) -> None:
+ """Raise ForbiddenAccess if user lacks system:resource:assign-owner on the system resource."""
+ _sys = system_resource(conn)
+ sys_roles = user_roles_on_resource(conn, user.user_id, _sys.resource_id)
+ sys_privs = tuple(
+ priv.privilege_id for role in sys_roles for priv in role.privileges)
+ if "system:resource:assign-owner" not in sys_privs:
+ raise ForbiddenAccess(
+ "You need the 'system:resource:assign-owner' privilege.")
+
+
+@resources.route("/<uuid:resource_id>/assign-owner", methods=["POST"])
+def assign_resource_owner(resource_id: UUID) -> Response:
+ """Assign the resource-owner role to a user on the given resource.
+
+ Only users with system:resource:assign-owner (sysadmins) may call this.
+ This is the correct path to bootstrap ownership on a resource that has
+ no owner yet.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
+
+
+@resources.route("/<uuid:resource_id>/revoke-owner", methods=["POST"])
+def revoke_resource_owner(resource_id: UUID) -> Response:
+ """Revoke the resource-owner role from a user on the given resource.
+
+ Requires the same system:resource:assign-owner privilege as assign-owner.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
diff --git a/gn_auth/auth/system/admin/users.py b/gn_auth/auth/system/admin/users.py
new file mode 100644
index 0000000..7dfc7ca
--- /dev/null
+++ b/gn_auth/auth/system/admin/users.py
@@ -0,0 +1,232 @@
+"""Administrative endpoints for user management."""
+import sqlite3
+from functools import reduce
+from typing import Sequence
+
+from flask import jsonify, Response, Blueprint, make_response, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from email_validator import validate_email, EmailNotValidError
+
+from gn_libs.privileges.system import can_create_or_delete_user
+
+from gn_auth.auth.errors import (
+ PasswordError,
+ UsernameError,
+ ForbiddenAccess,
+ UserRegistrationError)
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.system.models import user_roles_on_system
+from gn_auth.auth.authorisation.users.admin.models import create_verified_user
+from gn_auth.auth.authorisation.users.views import (
+ validate_password,
+ validate_username)
+
+users = Blueprint("users", __name__)
+
+
+@users.route("/create", methods=["POST"])
+def create_user() -> Response:
+ """Create a new user. Requires system:user:create-user privilege."""
+ with (require_oauth.acquire("profile") as token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ u_roles = user_roles_on_system(conn, token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:create-user` privilege.")
+ form = request_json()
+ errors = {}
+ try:
+ email = validate_email(
+ form.get("email", "").strip(), check_deliverability=False)
+ except EmailNotValidError as enve:
+ errors["email"] = (
+ f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}")
+
+ try:
+ username = validate_username(form.get("name", "").strip())
+ except UsernameError as uerr:
+ errors["name"] = str(uerr.args[0])
+
+ try:
+ passwd = validate_password(
+ form.get("password", "").strip(),
+ form.get("password", "").strip())
+ except PasswordError as perr:
+ errors["password"] = str(perr.args[0])
+
+ if len(tuple(errors.keys())) > 0:
+ raise UserRegistrationError(tuple(errors.values()))
+
+ user = create_verified_user(conn, email.normalized, username, passwd)
+
+ return make_response(jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ }), 201)
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+ """Recovery function with dismal performance."""
+ _errors = tuple()
+ for _user_id in user_ids:
+ for _table, _col in tables:
+ try:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col}=?",
+ (str(_user_id),))
+ except sqlite3.IntegrityError:
+ _errors = _errors + (
+ (("user_id", _user_id),
+ ("reason", f"User has data in table {_table}")),)
+
+ return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+ """Fetch detail for non-deletable users."""
+ def __merge__(acc, curr):
+ _curr = dict(curr)
+ _this_dict = acc.get(
+ curr["user_id"], {"reasons": tuple()})
+ _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+ return {**acc, curr["user_id"]: _this_dict}
+
+ _reasons_by_id = reduce(__merge__,
+ (dict(row) for row in ids_and_reasons),
+ {})
+ _user_ids = tuple(_reasons_by_id.keys())
+ _paramstr = ", ".join(["?"] * len(_user_ids))
+ cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+ _user_ids)
+ return tuple({
+ "user": dict(row),
+ "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+ } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+ user_ids: tuple[str, ...],
+ dbrows: Sequence[sqlite3.Row],
+ reason: str
+ ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+ """Build a list of 'non-deletable' user objects."""
+ return tuple((("user_id", _uid), ("reason", reason))
+ for _uid in user_ids
+ if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+def delete_users() -> Response:
+ """Delete the specified users. Requires system:user:delete-user privilege."""
+ with (require_oauth.acquire("profile user role") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ u_roles = user_roles_on_system(conn, _token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set()
+ if str(_token.user.user_id) in _user_ids:
+ _non_deletable.add(
+ (("user_id", str(_token.user.user_id),),
+ ("reason", "You are not allowed to delete yourself.")))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is member of a user group."))
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is registered owner of an OAuth client."))
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ f"User holds on of the following roles: {_important_roles}"))
+
+ _delete = tuple(uid for uid in _user_ids if uid not in
+ (dict(row)["user_id"] for row in _non_deletable))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ try:
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+ except sqlite3.IntegrityError:
+ _non_deletable.update(__delete_users_individually__(
+ cursor, _delete, _dependent_tables))
+
+ _not_deleted = __fetch_non_deletable_users__(
+ cursor, _non_deletable)
+ _delete = tuple(# rebuild with those that failed.
+ _user_id for _user_id in _delete if _user_id not in
+ tuple(row["user"]["user_id"] for row in _not_deleted))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _not_deleted,
+ "deleted": _deleted_rows,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (" Some users could not be deleted."
+ if len(_user_ids) - _deleted_rows > 0
+ else ""))
+ })
+
+ _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+ return make_response(jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": _not_deleted,
+ "deleted": 0,
+ "error": "Zero users were deleted",
+ "error_description": (
+ "No users were selected for deletion."
+ if len(_user_ids) == 0
+ else ("The selected users are system administrators, group "
+ "members, or resource owners."))
+ }), 400)
diff --git a/gn_auth/auth/system/admin/views.py b/gn_auth/auth/system/admin/views.py
new file mode 100644
index 0000000..f1f76f9
--- /dev/null
+++ b/gn_auth/auth/system/admin/views.py
@@ -0,0 +1,10 @@
+"""Administrative endpoints."""
+
+from flask import Blueprint
+
+from .resources import resources
+from .users import users
+
+admin = Blueprint("admin", __name__)
+admin.register_blueprint(resources, url_prefix="/resources")
+admin.register_blueprint(users, url_prefix="/users")
diff --git a/gn_auth/auth/system/views.py b/gn_auth/auth/system/views.py
new file mode 100644
index 0000000..2cebfbc
--- /dev/null
+++ b/gn_auth/auth/system/views.py
@@ -0,0 +1,8 @@
+"""The Blueprints for this package."""
+
+from flask import Blueprint
+
+from .admin.views import admin
+
+systembp = Blueprint("sysadm", __name__)
+systembp.register_blueprint(admin, url_prefix="/administration")
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 6867f38..383dc9f 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -10,7 +10,9 @@ from .authorisation.roles.views import roles
from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
-from .authorisation.resources.system.views import system
+
+from .system.views import systembp
+
oauth2 = Blueprint("oauth2", __name__)
@@ -20,6 +22,6 @@ oauth2.register_blueprint(users, url_prefix="/user")
oauth2.register_blueprint(roles, url_prefix="/role")
oauth2.register_blueprint(admin, url_prefix="/admin")
oauth2.register_blueprint(groups, url_prefix="/group")
-oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
+oauth2.register_blueprint(systembp, url_prefix="/system")
diff --git a/gn_auth/debug.py b/gn_auth/debug.py
deleted file mode 100644
index 6b7173b..0000000
--- a/gn_auth/debug.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Debug utilities"""
-import logging
-from flask import current_app
-
-__this_module_name__ = __name__
-
-
-# pylint: disable=invalid-name
-def getLogger(name: str):
- """Return a logger"""
- return (
- logging.getLogger(name)
- if not bool(current_app)
- else current_app.logger)
-
-def __pk__(*args):
- """Format log entry"""
- value = args[-1]
- title_vals = " => ".join(args[0:-1])
- logger = getLogger(__this_module_name__)
- logger.debug("%s: %s", title_vals, value)
- return value
diff --git a/gn_auth/errors/authlib.py b/gn_auth/errors/authlib.py
index 09862e3..c85b67c 100644
--- a/gn_auth/errors/authlib.py
+++ b/gn_auth/errors/authlib.py
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
def __description__(body):
"""Improve description for errors in authlib.oauth2.rfc6749.errors"""
- _desc = body["error_description"]
+ _desc = body.get("error_description", body["error"])
match body["error"]:
case "missing_authorization":
return (
diff --git a/gn_auth/hooks.py b/gn_auth/hooks.py
index bd7380b..f15107f 100644
--- a/gn_auth/hooks.py
+++ b/gn_auth/hooks.py
@@ -5,7 +5,7 @@ from typing import List
from flask import request_finished
from flask import request, current_app
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
def register_hooks(app):
"""Initialise hooks system on the application."""
diff --git a/gn_auth/migrations.py b/gn_auth/migrations/__init__.py
index 3451e07..6acb058 100644
--- a/gn_auth/migrations.py
+++ b/gn_auth/migrations/__init__.py
@@ -1,4 +1,5 @@
-"""Run the migrations in the app, rather than with yoyo CLI."""
+"""Migrations package: Provides the migrations, and some utility functions to
+help with dealing with migrations."""
from pathlib import Path
from typing import Union
diff --git a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
index d511f5d..d511f5d 100644
--- a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
+++ b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
diff --git a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
index 48bd663..48bd663 100644
--- a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
+++ b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
diff --git a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
index 29f92d4..29f92d4 100644
--- a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
+++ b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
diff --git a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
index 67720b2..67720b2 100644
--- a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
+++ b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
diff --git a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
index ce752ef..ce752ef 100644
--- a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
+++ b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
diff --git a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
index 76ffbef..76ffbef 100644
--- a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
+++ b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
diff --git a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
index 6c829b1..6c829b1 100644
--- a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
+++ b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
diff --git a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
index abc8895..abc8895 100644
--- a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
+++ b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
diff --git a/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
index 51e19e8..51e19e8 100644
--- a/migrations/auth/20221110_05_BaNtL-create-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
diff --git a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
index 2b55c2b..2b55c2b 100644
--- a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
diff --git a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
index 0d0eeb9..0d0eeb9 100644
--- a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
+++ b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
diff --git a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
index 077182b..077182b 100644
--- a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
+++ b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
diff --git a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
index 072f226..072f226 100644
--- a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
+++ b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
diff --git a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
index 2048f4a..2048f4a 100644
--- a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
+++ b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
diff --git a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
index 6bd101b..6bd101b 100644
--- a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
+++ b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
diff --git a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
index a7e7b45..a7e7b45 100644
--- a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
index 386f481..386f481 100644
--- a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
+++ b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
diff --git a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
index e0de751..e0de751 100644
--- a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
diff --git a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
index 2e4ae28..2e4ae28 100644
--- a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
+++ b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
diff --git a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
index a4d7806..a4d7806 100644
--- a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
+++ b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
diff --git a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
index 92885ef..92885ef 100644
--- a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
+++ b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
diff --git a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
index 9aa3667..9aa3667 100644
--- a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
+++ b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
diff --git a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
index 2238069..2238069 100644
--- a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
+++ b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
diff --git a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
index 475be01..475be01 100644
--- a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
+++ b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
diff --git a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
index 778282b..778282b 100644
--- a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
+++ b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
diff --git a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
index 1683f87..1683f87 100644
--- a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
+++ b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
diff --git a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
index 7e7fda2..7e7fda2 100644
--- a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
+++ b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
diff --git a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
index 1ef5ab0..1ef5ab0 100644
--- a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
+++ b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
diff --git a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
index ceae5ea..ceae5ea 100644
--- a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
+++ b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
diff --git a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
index 8b406a6..8b406a6 100644
--- a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
+++ b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
diff --git a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
index 9b3fc2b..9b3fc2b 100644
--- a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
+++ b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
diff --git a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
index 84bbd49..84bbd49 100644
--- a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
+++ b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
diff --git a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
index 3caad55..3caad55 100644
--- a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
+++ b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
diff --git a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
index 647325f..647325f 100644
--- a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
+++ b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
diff --git a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
index 7c9e986..7c9e986 100644
--- a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
diff --git a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
index 02e8718..02e8718 100644
--- a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
+++ b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
diff --git a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
index 1a865e0..1a865e0 100644
--- a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
diff --git a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
index db9a6bf..db9a6bf 100644
--- a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
+++ b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
diff --git a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
index 2ad1056..2ad1056 100644
--- a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
+++ b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
diff --git a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
index 37fcfe7..37fcfe7 100644
--- a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
+++ b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
diff --git a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
index c4397c9..c4397c9 100644
--- a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
+++ b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
diff --git a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
index 0f491c2..0f491c2 100644
--- a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
+++ b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
diff --git a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
index a26834a..a26834a 100644
--- a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
+++ b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
diff --git a/migrations/auth/20230912_01_BxrhE-add-system-resource.py b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
index 66c6461..66c6461 100644
--- a/migrations/auth/20230912_01_BxrhE-add-system-resource.py
+++ b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
diff --git a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
index 1b3f0b1..1b3f0b1 100644
--- a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
+++ b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
diff --git a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
index 1172034..1172034 100644
--- a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
+++ b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
diff --git a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
index 402e9a5..402e9a5 100644
--- a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
+++ b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
diff --git a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
index a4238ed..a4238ed 100644
--- a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
+++ b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
diff --git a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
index 049ac6b..049ac6b 100644
--- a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
+++ b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
diff --git a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
index 0cab1c3..0cab1c3 100644
--- a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
+++ b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
diff --git a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
index a45fd30..a45fd30 100644
--- a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
+++ b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
diff --git a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
index 0695c0e..0695c0e 100644
--- a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
diff --git a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
index 45d689c..45d689c 100644
--- a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
diff --git a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
index 44318bd..44318bd 100644
--- a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
+++ b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
index 5c6e81d..5c6e81d 100644
--- a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
+++ b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
diff --git a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
index d22ad01..d22ad01 100644
--- a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
+++ b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
diff --git a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
index 73a4880..73a4880 100644
--- a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
+++ b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
diff --git a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
index 3b9e928..3b9e928 100644
--- a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
+++ b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
diff --git a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
index 5d9c306..5d9c306 100644
--- a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
+++ b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
diff --git a/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
index 6335152..6335152 100644
--- a/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
+++ b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
diff --git a/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
index f00ab11..f00ab11 100644
--- a/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
+++ b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
diff --git a/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
index b956bef..b956bef 100644
--- a/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
+++ b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
diff --git a/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
index be0d022..be0d022 100644
--- a/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
+++ b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
diff --git a/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
index e79ab1c..e79ab1c 100644
--- a/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
+++ b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
diff --git a/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
index e3bdc8f..e3bdc8f 100644
--- a/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
+++ b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
diff --git a/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
index 95a6fbb..95a6fbb 100644
--- a/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
+++ b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
diff --git a/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
new file mode 100644
index 0000000..63e807a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
@@ -0,0 +1,61 @@
+"""
+add role systemwide-data-curator.
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members'}
+
+
+def create_systemwide_data_curator_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO roles(role_id, role_name, user_editable) "
+ "VALUES (?, 'systemwide-data-curator', 0)",
+ (str(uuid.uuid4()),))
+
+
+def link_privileges_to_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ role_id = cursor.fetchone()[0]
+ cursor.executemany("INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?)",
+ tuple((role_id, priv) for priv in
+ ("system:system-wide:data:edit",
+ "system:system-wide:data:delete")))
+
+
+def unlink_privileges_from_role(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ role_id = cursor.fetchone()[0]
+ cursor.executemany("DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ tuple((role_id, priv) for priv in
+ ("system:system-wide:data:edit",
+ "system:system-wide:data:delete")))
+
+
+steps = [
+ step(# Add new privileges
+ """
+ INSERT INTO privileges (privilege_id, privilege_description)
+ VALUES
+ ('system:system-wide:data:edit',
+ 'A user with this privilege can edit any data on the entire system.'),
+ ('system:system-wide:data:delete',
+ 'A user with this privilege can delete any data from the system.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id IN
+ ('system:system-wide:data:edit', 'system:system-wide:data:delete')"""),
+ step(create_systemwide_data_curator_role,
+ "DELETE FROM roles WHERE role_name='systemwide-data-curator'"),
+ step(link_privileges_to_role, unlink_privileges_from_role)
+]
diff --git a/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
new file mode 100644
index 0000000..d618f14
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
@@ -0,0 +1,62 @@
+"""
+add privilege for gn-docs documentation editing
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260206_01_v3f4P-add-role-systemwide-data-curator'}
+
+ROLE_NAME = 'systemwide-docs-editor'
+
+
+def create_systemwide_docs_editor_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO roles(role_id, role_name, user_editable) "
+ "VALUES (?, ?, 0)",
+ (str(uuid.uuid4()), ROLE_NAME))
+
+
+def delete_systemwide_docs_editor_role(conn):
+ """Create a new 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("DELETE FROM roles WHERE role_name=?", (ROLE_NAME,))
+
+
+def assign_edit_priv_to_docs_editor(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+ (ROLE_NAME,))
+ role_id = cursor.fetchone()[0]
+
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?)",
+ (role_id, "system:documentation:edit"))
+
+
+def revoke_edit_priv_to_docs_editor(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+ (ROLE_NAME,))
+ role_id = cursor.fetchone()[0]
+
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ (role_id, "system:documentation:edit"))
+
+
+steps = [
+ step(
+ """INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'system:documentation:edit',
+ 'Allows the holder to edit documentation presented with the Genenetwork system.'
+ )""",
+ "DELETE FROM privileges WHERE privilege_id='system:documentation:edit'"),
+ step(create_systemwide_docs_editor_role, delete_systemwide_docs_editor_role),
+ step(assign_edit_priv_to_docs_editor, revoke_edit_priv_to_docs_editor)
+]
diff --git a/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
new file mode 100644
index 0000000..e79ef6a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
@@ -0,0 +1,66 @@
+"""
+Assign 'systemwide-docs-editor' role to sysadmins
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing'}
+
+
+def fetch_docs_editor_role_id(cursor):
+ """Fetch ID of systemwide-docs-editor role"""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='systemwide-docs-editor'")
+ return cursor.fetchone()[0]
+
+
+def fetch_sys_resource_id(cursor):
+ """Fetch the resource ID of the system."""
+ cursor.execute("SELECT resource_id FROM resources "
+ "WHERE resource_name='GeneNetwork System'")
+ return cursor.fetchone()[0]
+
+
+def fetch_sys_admin_ids(cursor):
+ """Fetch the sysadmins' IDs."""
+ cursor.execute(
+ "SELECT user_roles.user_id FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id INNER JOIN roles "
+ "ON user_roles.role_id=roles.role_id "
+ "WHERE resources.resource_name='GeneNetwork System' "
+ "AND roles.role_name='system-administrator'")
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def __build_params__(cursor):
+ sysresourceid = fetch_sys_resource_id(cursor)
+ sysadminids = fetch_sys_admin_ids(cursor)
+ roleid = fetch_docs_editor_role_id(cursor)
+ return tuple({
+ "user_id": userid,
+ "role_id": roleid,
+ "resource_id": sysresourceid
+ } for userid in sysadminids)
+
+
+def assign_systemwide_docs_editor_role_to_sysadmins(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES(:user_id, :role_id, :resource_id)",
+ __build_params__(cursor))
+
+
+def revoke_systemwide_docs_editor_role_from_sysadmins(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "DELETE FROM user_roles WHERE user_id=:user_id "
+ "AND role_id=:role_id AND resource_id=:resource_id",
+ __build_params__(cursor))
+
+steps = [
+ step(assign_systemwide_docs_editor_role_to_sysadmins,
+ revoke_systemwide_docs_editor_role_from_sysadmins)
+]
diff --git a/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
new file mode 100644
index 0000000..bdf8a56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
@@ -0,0 +1,49 @@
+"""
+Restrict access to resources' 'Make Public' feature.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins'}
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+ "Fetch the role's ID."
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ return cursor.fetchone()[0]
+
+
+def assign_make_public_to_systemwide_data_curator(conn):
+ """Assign privilege to 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES(?, 'system:resource:make-public')",
+ (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+def revoke_make_public_from_systemwide_data_curator(conn):
+ """Revoke privilege from 'systemwide-data-curator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id='system:resource:make-public'",
+ (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES(
+ 'system:resource:make-public',
+ 'Allow user to make a resource publicly accessible.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id='system:resource:make-public'
+ """),
+ step(assign_make_public_to_systemwide_data_curator,
+ revoke_make_public_from_systemwide_data_curator),
+]
diff --git a/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
new file mode 100644
index 0000000..22863ae
--- /dev/null
+++ b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
@@ -0,0 +1,69 @@
+"""
+Add privileges to role systemwide-data-curator
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_03_vxBCX-restrict-access-to-resources-make-public-feature'}
+
+
+__new_privileges__ = (
+ ("system:system-wide:inbredset:view-case-attribute",
+ "Enable view of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:edit-case-attribute",
+ "Enable edit of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:delete-case-attribute",
+ "Enable deletion of any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:apply-case-attribute-edit",
+ "Enable applying changes to any and all inbredset case attributes system-wide."),
+ ("system:system-wide:inbredset:reject-case-attribute-edit",
+ "Enable rejecting changes to any and all inbredset case attributes system-wide."))
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+ "Fetch the role's ID."
+ cursor.execute("SELECT role_id FROM roles "
+ "WHERE role_name='systemwide-data-curator'")
+ return cursor.fetchone()[0]
+
+
+def create_new_privileges(conn):
+ """Create new privileges for the system."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany(
+ "INSERT INTO privileges(privilege_id, privilege_description) "
+ "VALUES (?, ?)",
+ __new_privileges__)
+
+
+def delete_new_privileges(conn):
+ """Delete these new privileges from the system."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.executemany("DELETE FROM privileges WHERE privilege_id=?",
+ tuple((priv[0],) for priv in __new_privileges__))
+
+
+def assign_new_privileges(conn):
+ """Assign the new privileges to the `systemwide-data-curator` role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_systemwide_data_curator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+ tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+def revoke_new_privileges(conn):
+ """Revoke the new privileges from the `systemwide-data-curator` role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_systemwide_data_curator_role_id(cursor)
+ cursor.executemany(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+ tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+
+steps = [
+ step(create_new_privileges, delete_new_privileges),
+ step(assign_new_privileges, revoke_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
new file mode 100644
index 0000000..702c418
--- /dev/null
+++ b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
@@ -0,0 +1,185 @@
+"""
+Add user and time tracking to resources table
+"""
+import random
+import contextlib
+from datetime import datetime
+
+from yoyo import step
+
+__depends__ = {'20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator'}
+
+GN_AUTH_INIT_TIMESTAMP = 1691130509.0
+__admin_id__ = ""
+
+
+def fetch_acentenos_id(conn):
+ """Fetch the default resource creator."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT user_id FROM users WHERE email=?",
+ (("acent" "eno@" "uthsc" "." "edu"),))
+ res = cursor.fetchone()
+ return res[0] if bool(res) else None
+
+
+def fetch_a_sysadmin_id(conn, resources_table):
+ """Fetch one ID out of all system administrator users."""
+ global __admin_id__
+
+ def __fetch__():
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ f"SELECT ur.user_id FROM {resources_table} AS rsc "
+ "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "WHERE resource_name='GeneNetwork System' "
+ "AND r.role_name='system-administrator'"
+ )
+ return tuple(row[0] for row in cursor.fetchall())
+
+ if not bool(__admin_id__):
+ __admins__ = __fetch__()
+ if len(__admins__) > 0:
+ __admin_id__ = random.choice(__admins__)
+
+ return __admin_id__
+
+
+def add_user_and_time_tracking_columns(conn):
+ """Add user and time tracking columns."""
+ conn.execute(
+ """
+ CREATE TABLE resources_new(
+ resource_id TEXT NOT NULL,
+ resource_name TEXT NOT NULL UNIQUE,
+ resource_category_id TEXT NOT NULL,
+ public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+ created_by TEXT NOT NULL,
+ created_at REAL NOT NULL DEFAULT '1691130509.0',
+ PRIMARY KEY(resource_id),
+ FOREIGN KEY(resource_category_id)
+ REFERENCES resource_categories(resource_category_id)
+ ON UPDATE CASCADE ON DELETE RESTRICT,
+ FOREIGN KEY(created_by)
+ REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE RESTRICT
+ ) WITHOUT ROWID
+ """)
+
+
+def drop_user_and_time_tracking_columns(conn):
+ """Drop user and time tracking columns."""
+ conn.execute("PRAGMA foreign_keys = OFF")
+ conn.execute("DROP TABLE IF EXISTS resources")
+ conn.execute("ALTER TABLE resources_old RENAME TO resources")
+ conn.execute("PRAGMA foreign_key_check")
+ conn.execute("PRAGMA foreign_keys = ON")
+
+
+def update_data_for_new_resources_table(conn):
+ """Add creator and time to original data."""
+ __creator__ = (
+ fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT * FROM resources")
+ cursor.executemany(
+ "INSERT INTO resources_new("
+ " resource_id,"
+ " resource_name,"
+ " resource_category_id,"
+ " public,"
+ " created_by,"
+ " created_at"
+ ") VALUES (?, ?, ?, ?, ?, ?)",
+ tuple(
+ tuple(row) + (__creator__, GN_AUTH_INIT_TIMESTAMP)
+ for row in cursor.fetchall()))
+
+
+def restore_data_for_old_resources_table(conn):
+ """Remove creator and time from data."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT * FROM resources")
+ cursor.executemany(
+ "INSERT INTO resources_old("
+ " resource_id,"
+ " resource_name,"
+ " resource_category_id,"
+ " public"
+ ") VALUES (?, ?, ?, ?)",
+ tuple(tuple(row)[0:4] for row in cursor.fetchall()))
+
+
+def replace_old_table_with_new_table(conn):
+ """Restore old resources table with the new resources table."""
+ conn.execute("PRAGMA foreign_keys = OFF")
+ conn.execute("DROP TABLE resources")
+ conn.execute("ALTER TABLE resources_new RENAME TO resources")
+ conn.execute("PRAGMA foreign_key_check")
+ conn.execute("PRAGMA foreign_keys = ON")
+
+
+def restore_old_table(conn):
+ """Restore old 'resources' table schema."""
+ conn.execute(
+ """
+ CREATE TABLE resources_old(
+ resource_id TEXT NOT NULL,
+ resource_name TEXT NOT NULL UNIQUE,
+ resource_category_id TEXT NOT NULL,
+ public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+ PRIMARY KEY(resource_id),
+ FOREIGN KEY(resource_category_id)
+ REFERENCES resource_categories(resource_category_id)
+ ON UPDATE CASCADE ON DELETE RESTRICT
+ ) WITHOUT ROWID
+ """)
+
+
+def parse_creator_and_time(cursor, row):
+ __return__ = None
+
+ __name_parts__ = row[1].split("—")
+ if len(__name_parts__) == 4:
+ __email__, __inbredsetname__, __datetimestr__, count = __name_parts__
+ cursor.execute("SELECT user_id FROM users WHERE email=?",
+ (__email__.strip(),))
+ results = cursor.fetchone()
+ if bool(results):
+ __return__ = {
+ "resource_id": row[0],
+ "creator": results[0],
+ "created": datetime.fromisoformat(__datetimestr__).timestamp()
+ }
+
+ return __return__
+
+
+def update_creators_and_time(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute("SELECT resource_id, resource_name FROM resources")
+ cursor.executemany(
+ "UPDATE resources SET created_by=:creator, created_at=:created "
+ "WHERE resource_id=:resource_id",
+ tuple(item for item in
+ (parse_creator_and_time(cursor, row)
+ for row in cursor.fetchall())
+ if item is not None))
+
+
+
+def restore_default_creators_and_time(conn):
+ with contextlib.closing(conn.cursor()) as cursor:
+ __creator__ = (
+ fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+ cursor.execute("UPDATE resources SET created_by=?, created_at=?",
+ (__creator__, GN_AUTH_INIT_TIMESTAMP))
+
+
+steps = [
+ step(add_user_and_time_tracking_columns,
+ drop_user_and_time_tracking_columns),
+ step(update_data_for_new_resources_table,
+ restore_data_for_old_resources_table),
+ step(replace_old_table_with_new_table, restore_old_table),
+ step(update_creators_and_time, restore_default_creators_and_time)
+]
diff --git a/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
new file mode 100644
index 0000000..2dddc56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
@@ -0,0 +1,19 @@
+"""
+New privilege: system:system-wide:data:view
+"""
+
+from yoyo import step
+
+__depends__ = {'20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table'}
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES('system:system-wide:data:view',
+ 'A user with this privilege can view any data on the entire system.')
+ """,
+ """
+ DELETE FROM privileges WHERE privilege_id='system:system-wide:data:view'
+ """)
+]
diff --git a/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
new file mode 100644
index 0000000..537bf9b
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
@@ -0,0 +1,62 @@
+"""
+Add privileges to batch-editors role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_01_Tak6O-new-privilege-system-system-wide-data-view'}
+
+
+def fetch_batch_editors_role_id(cursor):
+ """Fetch the ID of the batch-editors role."""
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='Batch Editors'")
+ res = cursor.fetchone()
+ if not bool(res):
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='batch-editors'")
+ res = cursor.fetchone()
+
+ return res[0] if bool(res) else None
+
+
+def rename_role(conn):
+ """Rename role from 'Batch Editors' to 'batch-editors'."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "UPDATE roles SET role_name='batch-editors' WHERE role_id=?",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+def restore_old_role_name(conn):
+ """Rename role from 'batch-editors' to 'Batch Editors'."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "UPDATE roles SET role_name='Batch Editors' WHERE role_id=?",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+def add_new_privileges(conn):
+ """Add new privileges to 'batch-editors' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ role_id = fetch_batch_editors_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO role_privileges(role_id, privilege_id) VALUES(?, ?)",
+ tuple((role_id, priv) for priv in (
+ "system:system-wide:data:view",
+ "system:system-wide:data:edit")))
+
+
+def remove_new_privileges(conn):
+ """Remove new privileges from 'batch-editors' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges WHERE role_id=? AND privilege_id IN "
+ "('system:system-wide:data:view', 'system:system-wide:data:edit')",
+ (fetch_batch_editors_role_id(cursor),))
+
+
+steps = [
+ step(rename_role, restore_old_role_name),
+ step(add_new_privileges, remove_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py b/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py
new file mode 100644
index 0000000..89d2d0d
--- /dev/null
+++ b/gn_auth/migrations/auth/20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role.py
@@ -0,0 +1,40 @@
+"""
+Add privilege `system:user:list` to the `resource-owner` role.
+
+The resource-owner needs to be able to list the users in the system in order to
+actually select which user they want to assign a particular role to.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_02_L6zIV-add-privileges-to-batch-editors-role'}
+
+
+def fetch_resource_owner_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='resource-owner'")
+ return cursor.fetchone()[0]
+
+
+def assign_system_user_list_to_resource_owner(conn):
+ """Assign the 'system:user:list' privilege to the 'resource-owner' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_resource_owner_role_id(cursor), "system:user:list"))
+
+
+def revoke_system_user_list_from_resource_owner(conn):
+ """Revoke the 'system:user:list' privilege from the 'resource-owner' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_resource_owner_role_id(cursor), "system:user:list"))
+
+steps = [
+ step(assign_system_user_list_to_resource_owner,
+ revoke_system_user_list_from_resource_owner)
+]
diff --git a/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py
new file mode 100644
index 0000000..50e448f
--- /dev/null
+++ b/gn_auth/migrations/auth/20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role.py
@@ -0,0 +1,44 @@
+"""
+Add 'system:user:create-user' privilege to 'system-administrator' role.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260825_01_4uVPR-add-privilege-system-user-list-to-the-resource-owner-role'}
+
+
+def fetch_system_admin_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def assign_system_user_create_user_to_sys_admin(conn):
+ """Assign the 'system:user:create-user' privilege to the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_system_admin_role_id(cursor), "system:user:create-user"))
+
+
+def revoke_system_user_create_user_from_sys_admin(conn):
+ """Revoke the 'system:user:create-user' privilege from the 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_system_admin_role_id(cursor), "system:user:create-user"))
+
+
+steps = [
+ step(
+ """
+ INSERT INTO privileges(privilege_id, privilege_description)
+ VALUES('system:user:create-user', 'Create a user for the system')
+ """,
+ "DELETE FROM privileges WHERE privilege_id='system:user:create-user'"),
+ step(assign_system_user_create_user_to_sys_admin,
+ revoke_system_user_create_user_from_sys_admin)
+]
diff --git a/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py b/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py
new file mode 100644
index 0000000..4dd3f68
--- /dev/null
+++ b/gn_auth/migrations/auth/20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role.py
@@ -0,0 +1,39 @@
+"""
+Assign 'resource:user:assign-role' to the 'system-administrator' role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260826_01_6Hzl8-add-system-user-create-user-privilege-to-system-administrator-role'}
+
+
+def fetch_system_admin_role_id(cursor):
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def assign_assign_role_to_sys_admin(conn):
+ """Assign 'resource:user:assign-role' privilege to the
+ 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "INSERT INTO role_privileges(role_id, privilege_id) "
+ "VALUES (?, ?) "
+ "ON CONFLICT (role_id, privilege_id) DO NOTHING",
+ (fetch_system_admin_role_id(cursor), "resource:user:assign-role"))
+
+
+def revoke_assign_role_from_sys_admin(conn):
+ """Revoke 'resource:user:assign-role' privilege from the
+ 'system-administrator' role."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM role_privileges "
+ "WHERE role_id=? AND privilege_id=?",
+ (fetch_system_admin_role_id(cursor), "resource:user:assign-role"))
+
+
+steps = [
+ step(assign_assign_role_to_sys_admin, revoke_assign_role_from_sys_admin)
+]
diff --git a/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py b/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py
new file mode 100644
index 0000000..cb4b9bc
--- /dev/null
+++ b/gn_auth/migrations/auth/20260901_01_asMmm-revoke-system-administration-role-on-non-system-resources.py
@@ -0,0 +1,79 @@
+"""
+Revoke system-administration role on non-system resources.
+
+Migration `20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py`
+assigns the "system-administrator" role on all resources to the system-admin
+users. This is not the correct way of things.
+
+The correct way is that the "system-administrator" role can only ever be applied
+against the system itself, and the sys-admin can masquerade as whatever user
+they need to in order to accomplish their goal.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260831_01_VZp52-assign-resource-user-assign-role-to-the-system-administrator-role'}
+
+
+def system_administrator_role_id(cursor):
+ """Fetch ID for role 'system-administrator'."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+ return cursor.fetchone()[0]
+
+
+def system_resource_id(cursor):
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key = 'system'")
+ return cursor.fetchone()[0]
+
+
+def fetch_ids_for_sysadmin_users(cursor):
+ """Fetch all sysadmin users' IDs."""
+ cursor.execute(
+ "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+ "ON roles.role_id=user_roles.role_id "
+ "WHERE role_name='system-administrator' AND resource_id=?",
+ (system_resource_id(cursor),))
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def fetch_non_system_resources(cursor):
+ """Fetch IDs for all resources that are not of the 'system' category."""
+ cursor.execute(
+ "SELECT resources.resource_id FROM resource_categories "
+ "INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_category_key != 'system'")
+ return tuple(row[0] for row in cursor.fetchall())
+
+
+def revoke_sys_admin_role_on_non_system_resources(conn):
+ """Revoke the 'system-administration' role from non-system resources."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles WHERE role_id=? AND resource_id != ?",
+ (system_administrator_role_id(cursor), system_resource_id(cursor)))
+
+
+def restore_sys_admin_role_on_non_system_resources(conn):
+ """..."""
+ with contextlib.closing(conn.cursor()) as cursor:
+ sysadminroleid = system_administrator_role_id(cursor)
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, resource_id, role_id) "
+ "VALUES (?, ?, ?)",
+ tuple(item + (sysadminroleid,)
+ for item in itertools.product(
+ fetch_ids_for_sysadmin_users(cursor),
+ fetch_non_system_resources(cursor))))
+
+
+steps = [
+ step(revoke_sys_admin_role_on_non_system_resources,
+ restore_sys_admin_role_on_non_system_resources)
+]
diff --git a/migrations/auth/__init__.py b/gn_auth/migrations/auth/__init__.py
index 1358c9a..1358c9a 100644
--- a/migrations/auth/__init__.py
+++ b/gn_auth/migrations/auth/__init__.py
diff --git a/gn_auth/scripts/__init__.py b/gn_auth/scripts/__init__.py
new file mode 100644
index 0000000..5be56d8
--- /dev/null
+++ b/gn_auth/scripts/__init__.py
@@ -0,0 +1 @@
+"""These are command-line scripts to be run manually or in the background."""
diff --git a/scripts/assign_data_to_default_admin.py b/gn_auth/scripts/assign_data_to_default_admin.py
index 69fc50c..69fc50c 100644
--- a/scripts/assign_data_to_default_admin.py
+++ b/gn_auth/scripts/assign_data_to_default_admin.py
diff --git a/scripts/batch_assign_data_to_default_admin.py b/gn_auth/scripts/batch_assign_data_to_default_admin.py
index a468019..95d9794 100644
--- a/scripts/batch_assign_data_to_default_admin.py
+++ b/gn_auth/scripts/batch_assign_data_to_default_admin.py
@@ -15,8 +15,7 @@ from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.resources.groups.models import (
Group, db_row_to_group)
-
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
default_resources, assign_data_to_resource)
diff --git a/scripts/link_inbredsets.py b/gn_auth/scripts/link_inbredsets.py
index c78a050..ad743f5 100644
--- a/scripts/link_inbredsets.py
+++ b/gn_auth/scripts/link_inbredsets.py
@@ -10,7 +10,7 @@ from gn_libs import mysqldb as biodb
import gn_auth.auth.db.sqlite3 as authdb
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
sys_admins, admin_group, select_sys_admin)
def linked_inbredsets(conn):
diff --git a/scripts/register_sys_admin.py b/gn_auth/scripts/register_sys_admin.py
index 06aa845..06aa845 100644
--- a/scripts/register_sys_admin.py
+++ b/gn_auth/scripts/register_sys_admin.py
diff --git a/scripts/search_phenotypes.py b/gn_auth/scripts/search_phenotypes.py
index eee112d..eee112d 100644
--- a/scripts/search_phenotypes.py
+++ b/gn_auth/scripts/search_phenotypes.py
diff --git a/scripts/worker.py b/gn_auth/scripts/worker.py
index 0a77d41..0a77d41 100644
--- a/scripts/worker.py
+++ b/gn_auth/scripts/worker.py
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index d59e997..f903553 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -14,7 +14,7 @@ SESSION_EXPIRY_MINUTES = 10
# Database settings
SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl"
AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db"
-AUTH_MIGRATIONS = "migrations/auth"
+AUTH_MIGRATIONS = "gn_auth/migrations/auth"
# Redis settings
REDIS_URI = "redis://localhost:6379/0"
@@ -49,3 +49,5 @@ EMAIL_ADDRESS = "no-reply@uthsc.edu"
## Variable settings for various emails going out to users
AUTH_EMAILS_EXPIRY_MINUTES = 15
+
+LOGGABLE_MODULES = ["gn_auth"]
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index e05ef0d..b5c4499 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -1,10 +1,13 @@
"""Main entry point for project"""
+import os
+import re
+import secrets
import sys
import uuid
import json
from math import ceil
from pathlib import Path
-from datetime import datetime
+from datetime import datetime, timezone
import click
from yoyo import get_backend, read_migrations
@@ -14,10 +17,18 @@ from gn_auth import create_app
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
-from gn_auth.auth.authentication.users import user_by_id, hash_password
-from gn_auth.auth.authorisation.users.admin.models import make_sys_admin
-
-from scripts import register_sys_admin as rsysadm# type: ignore[import]
+from gn_auth.auth.authentication.users import (
+ user_by_id, hash_password, save_user, set_user_password)
+from gn_auth.auth.authorisation.roles.models import assign_default_roles
+from gn_auth.auth.authorisation.users.admin.models import (
+ make_sys_admin, grant_sysadmin_role)
+from gn_auth.auth.authorisation.users.models import delete_users_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+ OAuth2Client, save_client, delete_client,
+ client as oauth2_client_by_id)
+from gn_auth.scripts import register_sys_admin as rsysadm# type: ignore[import]
+from gn_auth.auth.authorisation.roles.models import assign_user_role_by_name
+from gn_auth.auth.authorisation.resources.system.models import system_resource
app = create_app()
@@ -127,6 +138,401 @@ def register_admin():
"""Register the administrator."""
rsysadm.register_admin(Path(app.config["AUTH_DB"]))
+
+_VALID_ROLES_ = ("system-admin", "none", "batch-editors",
+ "systemwide-docs-editor", "systemwide-data-curator")
+
+_TEST_EMAIL_DOMAIN_ = "regression-tests.genenetwork.org"
+
+
+def __normalise_name_for_email__(name: str) -> str:
+ """Lowercase and strip non-alphanumeric characters for use in an email."""
+ return re.sub(r"[^a-z0-9]", "", name.lower())
+
+
+def __create_one_user__(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: db.DbCursor,
+ name: str,
+ email: str,
+ password: str,
+ role: str,
+ sys_resource_id: uuid.UUID
+) -> dict:
+ """Create a single user in the DB and return their credential record."""
+ user = save_user(cursor, email, name, verified=True)
+ set_user_password(cursor, user, password)
+ assign_default_roles(cursor, user)
+ match(role):
+ case "system-admin":
+ grant_sysadmin_role(cursor, user)
+ case "none":
+ pass
+ case _:
+ assign_user_role_by_name(cursor, user, sys_resource_id, role)
+
+ return {
+ "user_id": str(user.user_id),
+ "name": user.name,
+ "email": user.email,
+ "password": password,
+ "role": role,
+ }
+
+
+def __parse_user_spec__(spec: str) -> dict:
+ """Parse 'key=value,key=value,...' into a dict."""
+ result = {}
+ for part in spec.split(","):
+ key, _, value = part.partition("=")
+ if key.strip():
+ result[key.strip()] = value.strip()
+ return result
+
+
+def __write_output__(data: dict, output_path) -> None:
+ """Write JSON data to a file with 0644 permissions, or stdout."""
+ text = json.dumps(data, indent=2)
+ if output_path is None:
+ print(text)
+ return
+ fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
+ with os.fdopen(fd, "w") as outfile:
+ outfile.write(text)
+
+
+@app.cli.command()
+@click.option("--user", "user_specs", multiple=True,
+ help='User spec: "name=...,email=...,password=...,role=..."')
+@click.option("--output", "output_path", type=click.Path(), default=None,
+ help="Write credentials as JSON to this file (default: stdout)")
+def create_users(user_specs, output_path):
+ """Create one or more users with specified credentials and roles.
+
+ Each --user option takes a comma-separated key=value string with the
+ following keys: name, email, password, role.
+
+ Valid roles: system-admin, none, batch-editors, systemwide-docs-editor,
+ systemwide-data-curator.
+ """
+ if not user_specs:
+ print("No users specified.", file=sys.stderr)
+ sys.exit(1)
+
+ records = []
+ with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+ sysresource = system_resource(conn)
+ for spec_str in user_specs:
+ spec = __parse_user_spec__(spec_str)
+ name = spec.get("name", "").strip()
+ email = spec.get("email", "").strip()
+ password = spec.get("password", "").strip()
+ role = spec.get("role", "none").strip()
+
+ if not name:
+ print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if not email:
+ print(f"Missing 'email' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if not password:
+ print(f"Missing 'password' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if role not in _VALID_ROLES_:
+ print(
+ f"Invalid role {role!r} in spec: {spec_str!r}. "
+ f"Valid roles: {_VALID_ROLES_}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ records.append(
+ __create_one_user__(cursor,
+ name,
+ email,
+ password,
+ role,
+ sysresource.resource_id))
+
+ __write_output__({"users": records}, output_path)
+
+
+@app.cli.command()
+@click.option("--user-id", "user_ids", multiple=True, type=click.UUID,
+ help="UUID of a user to delete (repeatable)")
+def delete_users(user_ids):
+ """Delete one or more users by ID, bypassing policy checks.
+
+ Removes users unconditionally regardless of their roles or group
+ memberships. Use with care — intended for test teardown and administration.
+ """
+ if not user_ids:
+ print("No user IDs specified.", file=sys.stderr)
+ sys.exit(1)
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ deleted = delete_users_by_id(conn, tuple(user_ids))
+ print(f"Deleted {deleted} user(s).")
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+ help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--user", "user_specs", multiple=True,
+ help='User spec: "name=...,role=..."')
+@click.option("--output", "output_path", required=True, type=click.Path(),
+ help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_users(session_timestamp, user_specs, output_path):
+ """Create ephemeral test users with auto-generated email and password.
+
+ Each --user option takes a comma-separated key=value string with the
+ following keys: name, role.
+
+ Email: <normalised-name><timestamp>@regression-tests.genenetwork.org
+ Password: randomly generated.
+
+ Output is written with 0644 permissions. Valid roles: system-admin, none,
+ batch-editors, systemwide-docs-editor, systemwide-data-curator.
+ """
+ if not user_specs:
+ print("No users specified.", file=sys.stderr)
+ sys.exit(1)
+
+ records = []
+ with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+ sysresource = system_resource(conn)
+ for spec_str in user_specs:
+ spec = __parse_user_spec__(spec_str)
+ name = spec.get("name", "").strip()
+ role = spec.get("role", "none").strip()
+
+ if not name:
+ print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+ sys.exit(1)
+ if role not in _VALID_ROLES_:
+ print(
+ f"Invalid role {role!r} in spec: {spec_str!r}. "
+ f"Valid roles: {_VALID_ROLES_}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ email = (f"{__normalise_name_for_email__(name)}"
+ f"{session_timestamp}@{_TEST_EMAIL_DOMAIN_}")
+ password = secrets.token_urlsafe(32)
+
+ records.append(
+ __create_one_user__(cursor,
+ name,
+ email,
+ password,
+ role,
+ sysresource.resource_id))
+
+ __write_output__(
+ {"session_timestamp": session_timestamp, "users": records},
+ output_path)
+
+
+_DEFAULT_GRANT_TYPES_ = (
+ "password",
+ "authorization_code",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
+)
+
+_DEFAULT_SCOPES_ = (
+ "profile", "group", "role", "resource",
+ "register-client", "user", "masquerade",
+ "migrate-data", "introspect",
+)
+
+
+def __create_one_client__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ conn,
+ client_name: str,
+ owner_user,
+ redirect_uris: tuple,
+ scopes: tuple = _DEFAULT_SCOPES_,
+ grant_types: tuple = _DEFAULT_GRANT_TYPES_,
+ jwks_uri: str = "",
+) -> dict:
+ """Create a single OAuth2 client and return its credential record."""
+ raw_secret = secrets.token_urlsafe(32)
+ the_client = OAuth2Client(
+ client_id=uuid.uuid4(),
+ client_secret=hash_password(raw_secret),
+ client_id_issued_at=datetime.now(tz=timezone.utc),
+ client_secret_expires_at=datetime.fromtimestamp(0),
+ client_metadata={
+ "client_name": client_name,
+ "token_endpoint_auth_method": [
+ "client_secret_post", "client_secret_basic"],
+ "client_type": "confidential",
+ "grant_types": list(grant_types),
+ "default_redirect_uri": redirect_uris[0] if redirect_uris else "",
+ "redirect_uris": list(redirect_uris),
+ "response_type": ["code", "token"],
+ "scope": list(scopes),
+ "public-jwks-uri": jwks_uri,
+ },
+ user=owner_user)
+ save_client(conn, the_client)
+ return {
+ "client_id": str(the_client.client_id),
+ "client_secret": raw_secret,
+ "client_name": client_name,
+ }
+
+
+@app.cli.command()
+@click.option("--name", "client_name", required=True,
+ help="Human-readable name for the OAuth2 client")
+@click.option("--owner-id", required=True, type=click.UUID,
+ help="UUID of the user who owns this client")
+@click.option("--redirect-uri", "redirect_uris", multiple=True,
+ help="Allowed redirect URI (repeatable)")
+@click.option("--scope", "scopes", multiple=True,
+ default=_DEFAULT_SCOPES_, show_default=False,
+ help="OAuth2 scope (repeatable; defaults to full scope set)")
+@click.option("--grant-type", "grant_types", multiple=True,
+ default=_DEFAULT_GRANT_TYPES_, show_default=False,
+ help="Grant type (repeatable; defaults to all standard types)")
+@click.option("--jwks-uri", default="",
+ help="URI to the client's public JWKS (optional)")
+@click.option("--output", "output_path", type=click.Path(), default=None,
+ help="Write credentials as JSON to this file (default: stdout)")
+def create_oauth2_client(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ client_name,
+ owner_id,
+ redirect_uris,
+ scopes,
+ grant_types,
+ jwks_uri,
+ output_path
+):
+ """Create an OAuth2 client with specified parameters.
+
+ Scopes and grant types default to the full standard set if not provided.
+ """
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ try:
+ owner = user_by_id(conn, owner_id)
+ except NotFoundError:
+ print(f"No user found with ID {owner_id}", file=sys.stderr)
+ sys.exit(1)
+ record = __create_one_client__(
+ conn, client_name, owner, redirect_uris, scopes, grant_types,
+ jwks_uri)
+
+ __write_output__({"client": record}, output_path)
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+ help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--users-file", required=True, type=click.Path(exists=True),
+ help="Credentials file produced by create-test-users")
+@click.option("--owner-role", default="system-admin", show_default=True,
+ help="Role of the user in users-file to assign as client owner")
+@click.option("--output", "output_path", required=True, type=click.Path(),
+ help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_oauth2_client(session_timestamp, users_file, owner_role,
+ output_path):
+ """Create an ephemeral OAuth2 client for a test session.
+
+ Reads the credentials file produced by create-test-users to find the
+ owner. Client name and secret are auto-generated using the session
+ timestamp. Output is written with 0600 permissions.
+ """
+ with open(users_file, encoding="utf8") as f:
+ users_data = json.load(f)
+
+ owner_record = next(
+ (u for u in users_data.get("users", []) if u["role"] == owner_role),
+ None)
+ if owner_record is None:
+ print(
+ f"No user with role {owner_role!r} found in {users_file}",
+ file=sys.stderr)
+ sys.exit(1)
+
+ client_name = f"gn-test-client-{session_timestamp}"
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ try:
+ owner = user_by_id(conn, uuid.UUID(owner_record["user_id"]))
+ except NotFoundError:
+ print(
+ f"Owner user {owner_record['user_id']!r} not found in DB",
+ file=sys.stderr)
+ sys.exit(1)
+ record = __create_one_client__(
+ conn,
+ client_name,
+ owner,
+ tuple(),
+ scopes=(
+ "profile", "group", "role", "resource",
+ "register-client", "user", "masquerade"))
+
+ __write_output__(
+ {"session_timestamp": session_timestamp, "client": record},
+ output_path)
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+ type=click.Path(exists=True),
+ help="Credentials file produced by create-oauth2-client or "
+ "create-test-oauth2-client")
+def delete_oauth2_client(credentials_path):
+ """Delete an OAuth2 client using a credentials file.
+
+ Reads the client_id from the given credentials file and removes the
+ client and all associated tokens from the database.
+ """
+ with open(credentials_path, encoding="utf8") as f:
+ data = json.load(f)
+
+ client_id_str = data.get("client", {}).get("client_id")
+ if not client_id_str:
+ print("No client_id found in credentials file.", file=sys.stderr)
+ sys.exit(1)
+
+ client_id = uuid.UUID(client_id_str)
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ the_client = oauth2_client_by_id(conn, client_id)
+ if the_client.is_nothing():
+ print(f"No client found with ID {client_id}", file=sys.stderr)
+ sys.exit(1)
+ delete_client(conn, the_client.value)
+ os.unlink(credentials_path)
+ print(f"Deleted OAuth2 client {client_id}.")
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+ type=click.Path(exists=True),
+ help="Credentials file produced by create-test-users")
+def delete_test_users(credentials_path):
+ """Delete ephemeral test users using a credentials file.
+
+ Reads the credentials file produced by create-test-users and deletes
+ all listed users unconditionally, bypassing policy checks. Intended
+ for CI test teardown.
+ """
+ with open(credentials_path, encoding="utf8") as f:
+ data = json.load(f)
+
+ user_ids = tuple(
+ uuid.UUID(u["user_id"]) for u in data.get("users", []))
+ if not user_ids:
+ print("No users found in credentials file.", file=sys.stderr)
+ sys.exit(1)
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ deleted = delete_users_by_id(conn, user_ids)
+ os.unlink(credentials_path)
+ print(f"Deleted {deleted} user(s).")
+
##### END: CLI Commands #####
if __name__ == '__main__':
diff --git a/migrations/__init__.py b/migrations/__init__.py
deleted file mode 100644
index cedf48d..0000000
--- a/migrations/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Migrations package"""
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f5f624d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "gn-auth"
+# version = "1.0.1"
+dynamic = ["version"] # Read from git, or elsewhere
+description = "Authentication/Authorisation server for GeneNetwork Services."
+requires-python = ">= 3.10"
+authors = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+dependencies = [
+"argon2-cffi>= 20.1.0",
+"click",
+"Flask>= 1.1.2",
+"mypy>= 0.790",
+"mypy-extensions>= 0.4.3",
+"mysqlclient>= 2.0.1",
+"pylint>= 2.5.3",
+"pymonad",
+"redis>= 3.5.3",
+"requests>= 2.25.1",
+"flask-cors", # with the `>= 3.0.9` specification, it breaks the build
+"gn-libs @ git+https://git.genenetwork.org/gn-libs"
+]
+maintainers = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+license = "AGPL-3.0"
+readme = {file = "README.md", content-type = "text/markdown"}
+
+[project.urls]
+Homepage = "https://git.genenetwork.org/gn-auth/"
+Repository = "https://git.genenetwork.org/gn-auth/"
+
+[dependency-groups]# PEP 735
+tests = ["pytest"]
+checks = [{include-group = "tests"}, "mypy", "pylint", "vulture"]
+
+[tool.pylint.main]
+ignore = ["tests", "venv"]
+ignore-paths = ["^gn_auth/migrations/auth/.*"]
+ignore-imports = true
+disable = ["fixme", "duplicate-code", "no-else-return"]
+load-plugins = ["pylint.extensions.no_self_use"]
+
+[tool.vulture]
+ignore_decorators = [
+"@admin.before_request",
+"@admin.route",
+"@app.cli.command",
+"@auth.route",
+"@collections.route",
+"@data.route",
+"@genobp.route",
+"@groups.route",
+"@masq.route",
+"@misc.route",
+"@phenobp.route",
+"@phenosbp.route",
+"@popbp.route",
+"@privileges.route",
+"@resources.route",
+"@roles.route",
+"@system.route",
+"@users.route"
+]
+exclude = ["*/tests/unit/*", "*/gn_auth/settings.py", "*/gn_auth/migrations/*"]
+min_confidence = 60 \ No newline at end of file
diff --git a/scripts/__init__.py b/scripts/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/scripts/__init__.py
+++ /dev/null
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 41d118e..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[aliases]
-run_unit_tests = run_tests --type=unit
-run_integration_tests = run_tests --type=integration
-run_performance_tests = run_tests --type=performance
diff --git a/setup.py b/setup.py
deleted file mode 100755
index 77e5eb3..0000000
--- a/setup.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-"""Setup script for GeneNetwork Auth package."""
-from setuptools import setup, find_packages
-from setup_commands import RunTests
-
-LONG_DESCRIPTION = """
-gn-auth project is the authentication/authorisation server to be used
-across all GeneNetwork services.
-"""
-
-setup(author="Frederick M. Muriithi",
- author_email="fredmanglis@gmail.com",
- description=(
- "Authentication/Authorisation server for GeneNetwork Services."),
- install_requires=[
- "argon2-cffi>=20.1.0",
- "click",
- "Flask>=1.1.2",
- "mypy>=0.790",
- "mypy-extensions>=0.4.3",
- "mysqlclient>=2.0.1",
- "pylint>=2.5.3",
- "pymonad",
- "redis>=3.5.3",
- "requests>=2.25.1",
- "flask-cors>=3.0.9",
- "gn-libs>=0.0.0"
- ],
- include_package_data=True,
- packages=find_packages(
- where=".",
- exclude=(
- "tests",
- "tests.*",
- "setup_commands",
- "setup_commands.*")),
- # `package_data` doesn't seem to work. Use MANIFEST.in instead
- scripts=[],
- license="AGPLV3",
- long_description=LONG_DESCRIPTION,
- long_description_content_type="text/markdown",
- name="gn-auth",
- url="https://github.com/genenetwork/gn-auth",
- version="0.0.1",
- tests_require=["pytest", "hypothesis"],
- cmdclass={
- "run_tests": RunTests # testing
- })
diff --git a/setup_commands/__init__.py b/setup_commands/__init__.py
deleted file mode 100644
index 967bb11..0000000
--- a/setup_commands/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Module for custom setup commands."""
-
-from .run_tests import RunTests
diff --git a/setup_commands/run_tests.py b/setup_commands/run_tests.py
deleted file mode 100644
index 1bb5dab..0000000
--- a/setup_commands/run_tests.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import os
-import sys
-from distutils.core import Command
-
-class RunTests(Command):
- """
- A custom command to run tests.
- """
- description = "Run the tests"
- test_types = (
- "all", "unit", "integration", "performance")
- user_options = [
- ("type=", None,
- f"""Specify the type of tests to run.
- Valid types are {tuple(test_types)}.
- Default is `all`.""")]
-
- def __init__(self, dist):
- """Initialise the command."""
- super().__init__(dist)
- self.command = "pytest"
-
- def initialize_options(self):
- """Initialise the default values of all the options."""
- self.type = "all"
-
- def finalize_options(self):
- """Set final value of all the options once they are processed."""
- if self.type not in RunTests.test_types:
- raise Exception(f"""
- Invalid test type (self.type) requested!
- Valid types are
- {tuple(RunTests.test_types)}""")
-
- if self.type != "all":
- self.command = f"pytest -m {self.type}_test"
- def run(self):
- """Run the chosen tests"""
- print(f"Running {self.type} tests")
- os.system(self.command)
diff --git a/tests/unit/auth/fixtures/group_fixtures.py b/tests/unit/auth/fixtures/group_fixtures.py
index 2e8cd9a..da1c4cd 100644
--- a/tests/unit/auth/fixtures/group_fixtures.py
+++ b/tests/unit/auth/fixtures/group_fixtures.py
@@ -1,5 +1,6 @@
"""Fixtures and utilities for group-related tests"""
import uuid
+import datetime
import pytest
@@ -7,8 +8,12 @@ from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authorisation.resources.groups import Group
from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
+from .user_fixtures import TEST_USERS
from .resource_fixtures import TEST_RESOURCES
+
+_created_ = datetime.datetime.now()
+
TEST_GROUP_01 = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"),
"TheTestGroup", {})
TEST_GROUP_02 = Group(uuid.UUID("e37d59d7-c05e-4d67-b479-81e627d8d634"),
@@ -24,16 +29,20 @@ GROUPS_AS_RESOURCES = tuple({
"resource_id": res_id,
"resource_name": group.group_name,
"category_id": str(GROUP_CATEGORY.resource_category_id),
- "public": "0"
+ "public": "0",
+ "created_by": str(TEST_USERS[0].user_id),
+ "created_at": _created_.timestamp()
} for res_id, group in zip(
("38d1807d-105f-44a7-8327-7e2d973b6d8d",
"89458ef6-e090-4b53-8c2c-59eaf2785f11"),
TEST_GROUPS))
GROUP_RESOURCES = tuple(
- Resource(uuid.UUID(row["resource_id"]),
- row["resource_name"],
+ Resource(uuid.UUID(row["resource_id"]),# type: ignore[arg-type]
+ row["resource_name"],# type: ignore[arg-type]
GROUP_CATEGORY,
- False)
+ False,
+ created_by=TEST_USERS[0],
+ created_at=_created_)
for row in GROUPS_AS_RESOURCES)
@@ -46,7 +55,7 @@ def __gtuple__(cursor):
return tuple(dict(row) for row in cursor.fetchall())
@pytest.fixture(scope="function")
-def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name]
+def fxtr_group(conn_after_auth_migrations, fxtr_users):# pylint: disable=[redefined-outer-name, unused-argument]
"""Fixture: setup a test group."""
with db.cursor(conn_after_auth_migrations) as cursor:
cursor.executemany(
@@ -57,7 +66,7 @@ def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-na
cursor.executemany(
"INSERT INTO resources "
- "VALUES(:resource_id, :resource_name, :category_id, :public)",
+ "VALUES(:resource_id, :resource_name, :category_id, :public, :created_by, :created_at)",
GROUPS_AS_RESOURCES)
cursor.executemany(
diff --git a/tests/unit/auth/fixtures/resource_fixtures.py b/tests/unit/auth/fixtures/resource_fixtures.py
index e06f64e..b570a49 100644
--- a/tests/unit/auth/fixtures/resource_fixtures.py
+++ b/tests/unit/auth/fixtures/resource_fixtures.py
@@ -1,11 +1,15 @@
"""Fixtures and utilities for resource-related tests"""
import uuid
+import datetime
import pytest
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
+from .user_fixtures import TEST_USERS
+
+_created_ = datetime.datetime.now()
SYSTEM_CATEGORY = ResourceCategory(
uuid.UUID("aa3d787f-af6a-44fa-9b0b-c82d40e54ad2"),
@@ -15,48 +19,74 @@ SYSTEM_RESOURCE = Resource(
uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
"GeneNetwork System",
SYSTEM_CATEGORY,
- True)
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[4],
+ created_at=_created_)
TEST_RESOURCES = (
Resource(uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"),
"ResourceG01R01",
ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
"genotype", "Genotype Dataset"),
- True),
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
"ResourceG01R02",
ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"),
"phenotype", "Phenotype (Publish) Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
"ResourceG01R03",
ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
"mrna", "mRNA Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"),
"ResourceG02R01",
ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
"genotype", "Genotype Dataset"),
- False),
+ False,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_),
Resource(uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
"ResourceG02R02",
ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
"mrna", "mRNA Dataset"),
- True))
+ True,
+ resource_data=tuple(),
+ created_by=TEST_USERS[0],
+ created_at=_created_))
TEST_RESOURCES_PUBLIC = (SYSTEM_RESOURCE, TEST_RESOURCES[0], TEST_RESOURCES[4])
@pytest.fixture(scope="function")
-def fxtr_resources(conn_after_auth_migrations):
+def fxtr_resources(conn_after_auth_migrations, fxtr_users):# pylint: disable=[unused-argument]
"""fixture: setup test resources in the database"""
conn = conn_after_auth_migrations
with db.cursor(conn) as cursor:
cursor.executemany(
- "INSERT INTO resources VALUES (?,?,?,?)",
+ "INSERT INTO resources VALUES (?,?,?,?,?,?)",
((str(res.resource_id), res.resource_name,
str(res.resource_category.resource_category_id),
- 1 if res.public else 0) for res in TEST_RESOURCES))
+ 1 if res.public else 0,
+ str(res.created_by.user_id),
+ res.created_at.timestamp()) for res in TEST_RESOURCES))
+ cursor.execute(
+ "UPDATE resources SET created_by=?, created_at=? "
+ "WHERE resource_id=?",
+ (str(SYSTEM_RESOURCE.created_by.user_id),
+ SYSTEM_RESOURCE.created_at.timestamp(),
+ str(SYSTEM_RESOURCE.resource_id)))
yield (conn, TEST_RESOURCES)
diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py
index 63a3fca..24e8e9f 100644
--- a/tests/unit/auth/fixtures/role_fixtures.py
+++ b/tests/unit/auth/fixtures/role_fixtures.py
@@ -108,7 +108,7 @@ def fxtr_resource_roles(fxtr_resources, fxtr_roles):# pylint: disable=[redefined
@pytest.fixture(scope="function")
-def fxtr_setup_group_leaders(fxtr_users):
+def fxtr_setup_group_leaders(fxtr_users, fxtr_group):# pylint: disable=[unused-argument]
"""Define what roles users have that target resources of type 'Group'."""
conn, users = fxtr_users
with db.cursor(conn) as cursor:
diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py
index 1cf0e20..0872142 100644
--- a/tests/unit/auth/fixtures/user_fixtures.py
+++ b/tests/unit/auth/fixtures/user_fixtures.py
@@ -1,28 +1,35 @@
"""Fixtures and utilities for user-related tests"""
import uuid
+import datetime
import pytest
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, hash_password
+_created_ = datetime.datetime.now()
+
TEST_USERS = (
User(uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er",
- "Group Leader"),
+ "Group Leader", created=_created_),
User(uuid.UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
- "group@mem.ber01", "Group Member 01"),
+ "group@mem.ber01", "Group Member 01", created=_created_),
User(uuid.UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
- "group@mem.ber02", "Group Member 02"),
+ "group@mem.ber02", "Group Member 02", created=_created_),
User(uuid.UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
- "unaff@iliated.user", "Unaffiliated User"))
+ "unaff@iliated.user", "Unaffiliated User", created=_created_),
+ User(uuid.UUID("60faf8a7-832b-471e-b6a0-bd4013f1fa0e"),
+ "sys@admin.user", "System Admin User", created=_created_))
@pytest.fixture(scope="function")
-def fxtr_users(conn_after_auth_migrations, fxtr_group):# pylint: disable=[redefined-outer-name, unused-argument]
+def fxtr_users(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name, unused-argument]
"""Fixture: setup test users."""
- query = "INSERT INTO users(user_id, email, name) VALUES (?, ?, ?)"
+ query = (
+ "INSERT INTO users(user_id, email, name, created) VALUES (?, ?, ?, ?)")
with db.cursor(conn_after_auth_migrations) as cursor:
cursor.executemany(query, (
- (str(user.user_id), user.email, user.name) for user in TEST_USERS))
+ (str(user.user_id), user.email, user.name, user.created.timestamp())
+ for user in TEST_USERS))
yield (conn_after_auth_migrations, TEST_USERS)
diff --git a/tests/unit/auth/test_admin_user_roles.py b/tests/unit/auth/test_admin_user_roles.py
new file mode 100644
index 0000000..7ce0b1f
--- /dev/null
+++ b/tests/unit/auth/test_admin_user_roles.py
@@ -0,0 +1,262 @@
+"""Tests for admin role-assignment HTTP endpoints."""
+import pytest
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.roles.models import assign_user_role_by_name
+
+from tests.unit.auth import conftest
+from tests.unit.auth.fixtures.resource_fixtures import SYSTEM_RESOURCE
+
+# Body used in all role-assign tests — assigning system-administrator on the
+# system resource is a real, migrations-seeded combination.
+_ASSIGN_BODY = {
+ "role_name": "system-administrator",
+ "resource_id": str(SYSTEM_RESOURCE.resource_id)
+}
+
+# Target user for assignment: unaff@iliated.user (no roles initially)
+_TARGET_USER = conftest.TEST_USERS[3]
+
+
+def _setup_admin_mock(conn, clients, mocker):
+ """Grant resource-owner role on SYSTEM_RESOURCE and mock the token.
+
+ resource-owner carries resource:user:assign-role, which is what the
+ endpoint checks. In production the caller would masquerade as the
+ resource owner; here we grant the role directly for test setup.
+ """
+ admin = conftest.TEST_USERS[4]
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, admin, SYSTEM_RESOURCE.resource_id, "resource-owner")
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ admin,
+ tuple(c for c in clients if c.user == admin)[0]))
+ return admin
+
+
+@pytest.mark.unit_test
+def test_assign_role_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_assign_role_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+def _revoke_assigned_role(conn):
+ """Remove the role row written by the success tests.
+
+ Keeps the DB in the state the fixtures expect — no user_roles entry for
+ _TARGET_USER — so teardown and any subsequent queries are not surprised.
+ """
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles "
+ "WHERE user_id=? "
+ "AND role_id=(SELECT role_id FROM roles WHERE role_name=?) "
+ "AND resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+
+
+@pytest.mark.unit_test
+def test_assign_role_admin_returns_200(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid role/resource body
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: 200 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ try:
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 200
+ finally:
+ _revoke_assigned_role(conn)
+
+
+def _assign_target_role(conn):
+ """Pre-assign system-administrator to _TARGET_USER on SYSTEM_RESOURCE.
+
+ Required setup for revoke tests: the endpoint can only revoke what exists.
+ """
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, _TARGET_USER, SYSTEM_RESOURCE.resource_id,
+ _ASSIGN_BODY["role_name"])
+
+
+def _cleanup_target_role(conn):
+ """Remove _TARGET_USER's system-administrator row if still present.
+
+ No-op when the revoke endpoint already deleted it; guards against
+ test failures that leave the DB dirty.
+ """
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "DELETE FROM user_roles "
+ "WHERE user_id=? "
+ "AND role_id=(SELECT role_id FROM roles WHERE role_name=?) "
+ "AND resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+
+
+@pytest.mark.unit_test
+def test_assign_role_persists_to_db(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid role/resource body
+ WHEN: POST /auth/user/<uid>/roles/assign
+ THEN: the user_roles row is present in the DB for that user/role/resource
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ try:
+ with fxtr_app.test_client() as http:
+ http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/assign",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "WHERE user_roles.user_id=? "
+ "AND roles.role_name=? "
+ "AND user_roles.resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+ assert cursor.fetchone()["cnt"] == 1
+ finally:
+ _revoke_assigned_role(conn)
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoint tests: POST /auth/user/<uid>/roles/revoke
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_revoke_role_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_revoke_role_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.authorisation.users.views.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+@pytest.mark.unit_test
+def test_revoke_role_admin_returns_200(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token with resource:user:assign-role and the target user
+ holds the role on the resource
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: 200 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ _assign_target_role(conn)
+ try:
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 200
+ finally:
+ _cleanup_target_role(conn)
+
+
+@pytest.mark.unit_test
+def test_revoke_role_removes_from_db(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token with resource:user:assign-role and the target user
+ holds the role on the resource
+ WHEN: POST /auth/user/<uid>/roles/revoke
+ THEN: the user_roles row is absent from the DB
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ _assign_target_role(conn)
+ try:
+ with fxtr_app.test_client() as http:
+ http.post(
+ f"/auth/user/{_TARGET_USER.user_id}/roles/revoke",
+ json=_ASSIGN_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "WHERE user_roles.user_id=? "
+ "AND roles.role_name=? "
+ "AND user_roles.resource_id=?",
+ (str(_TARGET_USER.user_id),
+ _ASSIGN_BODY["role_name"],
+ _ASSIGN_BODY["resource_id"]))
+ assert cursor.fetchone()["cnt"] == 0
+ finally:
+ _cleanup_target_role(conn)
diff --git a/tests/unit/auth/test_admin_users.py b/tests/unit/auth/test_admin_users.py
new file mode 100644
index 0000000..eaa5bcd
--- /dev/null
+++ b/tests/unit/auth/test_admin_users.py
@@ -0,0 +1,190 @@
+"""Tests for admin user-management: model functions and HTTP endpoints."""
+import pytest
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.authorisation.users.admin.models import (
+ create_verified_user,
+ grant_sysadmin_role)
+
+from tests.unit.auth import conftest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _credential_count(conn, user_id: str) -> int:
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_credentials WHERE user_id=?",
+ (user_id,))
+ return cursor.fetchone()["cnt"]
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_sets_verified_flag(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: the returned user has verified=True and the flag is persisted in the DB
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "new@example.org", "New User", "s3cr3t")
+ assert user.verified is True
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT verified FROM users WHERE user_id=?", (str(user.user_id),))
+ row = cursor.fetchone()
+ assert row is not None
+ assert bool(row["verified"]) is True
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_has_no_roles(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: the new user is assigned no roles
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "noroles@example.org", "No Roles User", "s3cr3t")
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT COUNT(*) AS cnt FROM user_roles WHERE user_id=?",
+ (str(user.user_id),))
+ row = cursor.fetchone()
+ assert row["cnt"] == 0
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_stores_credentials(conn_after_auth_migrations):
+ """
+ GIVEN: a database with migrations applied
+ WHEN: create_verified_user is called with valid email, name and password
+ THEN: a password credential row is stored for the new user
+ """
+ conn = conn_after_auth_migrations
+ user = create_verified_user(conn, "creds@example.org", "Creds User", "s3cr3t")
+ assert _credential_count(conn, str(user.user_id)) == 1
+
+
+@pytest.mark.unit_test
+def test_create_verified_user_raises_on_duplicate_email(conn_after_auth_migrations):
+ """
+ GIVEN: a user already exists with a given email
+ WHEN: create_verified_user is called with the same email
+ THEN: an exception is raised
+ """
+ conn = conn_after_auth_migrations
+ create_verified_user(conn, "dupe@example.org", "First User", "s3cr3t")
+ with pytest.raises(Exception):
+ create_verified_user(conn, "dupe@example.org", "Second User", "s3cr3t")
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoint tests: POST /auth/system/administration/users/create
+# ---------------------------------------------------------------------------
+
+_NEW_USER_BODY = {"email": "newbie@example.org", "name": "Newbie", "password": "s3cr3t"}
+_CREATE_URL = "/auth/system/administration/users/create"
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_CREATE_URL, json=_NEW_USER_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_non_admin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token belonging to a non-admin user
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ user = conftest.TEST_USERS[3] # unaff@iliated.user — no privileges
+ mocker.patch(
+ "gn_auth.auth.system.admin.users.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json=_NEW_USER_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+def _setup_admin_mock(conn, clients, mocker):
+ """Grant sysadmin role and mock the token for sys@admin.user."""
+ admin = conftest.TEST_USERS[4]
+ with db.cursor(conn) as cursor:
+ grant_sysadmin_role(cursor, admin)
+ mocker.patch(
+ "gn_auth.auth.system.admin.users.require_oauth.acquire",
+ conftest.get_tokeniser(
+ admin,
+ tuple(c for c in clients if c.user == admin)[0]))
+ return admin
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_admin_returns_201(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid request body
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 201 is returned
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "s3cr3tP4ssw0rd"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 201
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_admin_returns_new_user(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a valid request body
+ WHEN: POST /auth/system/administration/users/create
+ THEN: the response body contains the new user's email and name
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "s3cr3tP4ssw0rd"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ data = res.get_json()
+ assert data.get("email") == _NEW_USER_BODY["email"]
+ assert data.get("name") == _NEW_USER_BODY["name"]
+ assert res.status_code == 201
+
+
+@pytest.mark.unit_test
+def test_create_user_endpoint_short_password_returns_400(
+ fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid system-admin token and a request body with a short password
+ WHEN: POST /auth/system/administration/users/create
+ THEN: 400 is returned (password must be at least 8 characters)
+ """
+ conn, clients = fxtr_oauth2_clients
+ _setup_admin_mock(conn, clients, mocker)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _CREATE_URL,
+ json={**_NEW_USER_BODY, "password": "short"},
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 400
diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py
index 346beb9..6f1e8cd 100644
--- a/tests/unit/auth/test_groups.py
+++ b/tests/unit/auth/test_groups.py
@@ -61,6 +61,8 @@ def __cleanup_create_group__(conn, user, group):
(str(user.user_id), str(grp_rsc["resource_id"])))
cursor.execute("DELETE FROM group_resources WHERE group_id=?",
(str(group.group_id),))
+ cursor.execute("DELETE FROM resources WHERE resource_id=?",
+ (grp_rsc["resource_id"],))
cursor.execute("DELETE FROM groups WHERE group_id=?",
(str(group.group_id),))
diff --git a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
index c34a549..a32cacb 100644
--- a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
+++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
@@ -8,7 +8,7 @@ from gn_auth.migrations import get_migration, apply_migrations, rollback_migrati
from tests.unit.auth.conftest import (
apply_single_migration, rollback_single_migration, migrations_up_to)
-MIGRATION_PATH = "migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
+MIGRATION_PATH = "gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
@pytest.mark.unit_test
def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend):
diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py
index 04da6df..81f967e 100644
--- a/tests/unit/auth/test_resources.py
+++ b/tests/unit/auth/test_resources.py
@@ -114,19 +114,19 @@ def test_public_resources(fxtr_resources):
"user,expected",
tuple(zip(
conftest.TEST_USERS,
- (sorted(
+ ((sorted(
{res.resource_id: res for res in
((conftest.GROUP_RESOURCES[0],) +
conftest.TEST_RESOURCES_GROUP_01 +
conftest.TEST_RESOURCES_PUBLIC)}.values(),
- key=sort_key_resources),
- sorted(
+ key=sort_key_resources), 6),
+ (sorted(
{res.resource_id: res for res in
((conftest.TEST_RESOURCES_GROUP_01[1],) +
conftest.TEST_RESOURCES_PUBLIC)}.values()
,
- key=sort_key_resources),
- PUBLIC_RESOURCES, PUBLIC_RESOURCES))))
+ key=sort_key_resources), 4),
+ (PUBLIC_RESOURCES, 3), (PUBLIC_RESOURCES, 3)))))
def test_user_resources(fxtr_resource_user_roles, user, expected):
"""
GIVEN: some resources in the database
@@ -134,6 +134,10 @@ def test_user_resources(fxtr_resource_user_roles, user, expected):
THEN: list only the resources for which the user can access
"""
conn, *_others = fxtr_resource_user_roles
+ uresources, count = user_resources(conn, user)
+ eresources, ecount = expected
+ assert count == ecount
assert sorted(
- {res.resource_id: res for res in user_resources(conn, user)
- }.values(), key=sort_key_resources) == expected
+ {res.resource_id: res for res in uresources}.values(),
+ key=sort_key_resources
+ ) == eresources
diff --git a/tests/unit/auth/test_system_admin_resources.py b/tests/unit/auth/test_system_admin_resources.py
new file mode 100644
index 0000000..76f3ca3
--- /dev/null
+++ b/tests/unit/auth/test_system_admin_resources.py
@@ -0,0 +1,94 @@
+"""Tests for system admin resource-ownership endpoints.
+
+Covers POST /auth/system/administration/resources/<id>/assign-owner
+ and POST /auth/system/administration/resources/<id>/revoke-owner.
+"""
+import pytest
+
+from tests.unit.auth import conftest
+from tests.unit.auth.fixtures.resource_fixtures import TEST_RESOURCES
+
+# Arbitrary target resource for the endpoint path — the privilege check fires
+# before any resource lookup, so the resource need not exist for 401/403 tests.
+_TARGET_RESOURCE = str(TEST_RESOURCES[0].resource_id)
+
+# Minimal body for both endpoints (the target user for ownership change).
+_BODY = {"user_id": str(conftest.TEST_USERS[3].user_id)}
+
+_ASSIGN_URL = f"/auth/system/administration/resources/{_TARGET_RESOURCE}/assign-owner"
+_REVOKE_URL = f"/auth/system/administration/resources/{_TARGET_RESOURCE}/revoke-owner"
+
+_NON_SYSADMIN = conftest.TEST_USERS[3] # unaff@iliated.user — no roles at all
+
+
+def _mock_token(mocker, user, clients):
+ """Patch require_oauth.acquire in the admin resources module."""
+ mocker.patch(
+ "gn_auth.auth.system.admin.resources.require_oauth.acquire",
+ conftest.get_tokeniser(
+ user,
+ tuple(c for c in clients if c.user == user)[0]))
+
+
+# ---------------------------------------------------------------------------
+# No-token tests (401)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_assign_owner_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST .../assign-owner
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_ASSIGN_URL, json=_BODY)
+ assert res.status_code == 401
+
+
+@pytest.mark.unit_test
+def test_revoke_owner_no_token_returns_401(fxtr_app):
+ """
+ GIVEN: no Authorization header
+ WHEN: POST .../revoke-owner
+ THEN: 401 is returned
+ """
+ with fxtr_app.test_client() as http:
+ res = http.post(_REVOKE_URL, json=_BODY)
+ assert res.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# Non-sysadmin tests (403)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.unit_test
+def test_assign_owner_non_sysadmin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token for a user without system:resource:assign-owner
+ WHEN: POST .../assign-owner
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ _mock_token(mocker, _NON_SYSADMIN, clients)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _ASSIGN_URL, json=_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403
+
+
+@pytest.mark.unit_test
+def test_revoke_owner_non_sysadmin_returns_403(fxtr_app, mocker, fxtr_oauth2_clients):
+ """
+ GIVEN: a valid token for a user without system:resource:assign-owner
+ WHEN: POST .../revoke-owner
+ THEN: 403 is returned
+ """
+ _conn, clients = fxtr_oauth2_clients
+ _mock_token(mocker, _NON_SYSADMIN, clients)
+ with fxtr_app.test_client() as http:
+ res = http.post(
+ _REVOKE_URL, json=_BODY,
+ headers={"Authorization": "Bearer some-mocked-token"})
+ assert res.status_code == 403