diff --git a/bento_authorization_service/cli.py b/bento_authorization_service/cli.py index bc3eb54..e42da41 100644 --- a/bento_authorization_service/cli.py +++ b/bento_authorization_service/cli.py @@ -102,7 +102,7 @@ async def get_group(db: Database, id_: int) -> int: print(json_model_dump_kwargs(g, sort_keys=True, indent=2)) return 0 - print("No group found with that ID.", file=sys.stderr) + print(f"No group found with ID: {id_}", file=sys.stderr) return 1 @@ -172,6 +172,32 @@ async def assign_all_cmd(_config: Config, db: Database, args) -> int: return 1 +async def add_grant_permissions_cmd(_config: Config, db: Database, args) -> int: + id_ = getattr(args, "grant_id", -1) + if (g := await db.get_grant(id_)) is not None: + if overlap := (ps := frozenset(args.permissions)).intersection(g.permissions): + print(f"Grant {id_} already has permissions {{{', '.join(overlap)}}}", file=sys.stderr) + else: + await db.add_grant_permissions(id_, ps) + return 0 + + print(f"No grant found with ID: {id_}", file=sys.stderr) + return 1 + + +async def set_grant_permissions_cmd(_config: Config, db: Database, args) -> int: + id_ = getattr(args, "grant_id", -1) + if (await db.get_grant(id_)) is not None: + await db.set_grant_permissions(id_, frozenset(args.permissions)) + return 0 + + print(f"No grant found with ID: {id_}", file=sys.stderr) + return 1 + + +ENTITY_KWARGS = dict(type=str, help="The type of entity to list.") + + async def main(args: list[str] | None, db: Database | None = None) -> int: cfg = get_config() args = args if args is not None else sys.argv[1:] @@ -183,16 +209,20 @@ async def main(args: list[str] | None, db: Database | None = None) -> int: subparsers = parser.add_subparsers() - entity_kwargs = dict(type=str, help="The type of entity to list.") - + # list ------------------------------------------------------------------------------------------------------------- l_sub = subparsers.add_parser("list") l_sub.set_defaults(func=list_cmd) - l_sub.add_argument("entity", choices=("permissions", "grants", "groups"), **entity_kwargs) + l_sub.add_argument("entity", choices=("permissions", "grants", "groups"), **ENTITY_KWARGS) + # ------------------------------------------------------------------------------------------------------------------ + # get -------------------------------------------------------------------------------------------------------------- g_sub = subparsers.add_parser("get") g_sub.set_defaults(func=get_cmd) - g_sub.add_argument("entity", choices=GET_DELETE_ENTITIES, **entity_kwargs) + g_sub.add_argument("entity", choices=GET_DELETE_ENTITIES, **ENTITY_KWARGS) g_sub.add_argument("id", type=int, help="Entity ID") + # ------------------------------------------------------------------------------------------------------------------ + + # create ----------------------------------------------------------------------------------------------------------- c = subparsers.add_parser("create") c_subparsers = c.add_subparsers() @@ -210,18 +240,34 @@ async def main(args: list[str] | None, db: Database | None = None) -> int: cr.add_argument("membership", type=str, help="JSON representation of the group membership.") cr.add_argument("--notes", type=str, default="", help="Optional human-readable notes to add to the group.") + # ------------------------------------------------------------------------------------------------------------------ + + # delete ----------------------------------------------------------------------------------------------------------- d_sub = subparsers.add_parser("delete") d_sub.set_defaults(func=delete_cmd) - d_sub.add_argument("entity", choices=GET_DELETE_ENTITIES, **entity_kwargs) + d_sub.add_argument("entity", choices=GET_DELETE_ENTITIES, **ENTITY_KWARGS) d_sub.add_argument("id", type=int, help="Entity ID") + # ------------------------------------------------------------------------------------------------------------------ - s_sub = subparsers.add_parser( + au_sub = subparsers.add_parser( "assign-all-to-user", help='Assigns all extant permissions for {"everything": true} to an issuer + subject combination.', ) - s_sub.set_defaults(func=assign_all_cmd) - s_sub.add_argument("iss", type=str, help="Issuer") - s_sub.add_argument("sub", type=str, help="Subject ID") + au_sub.set_defaults(func=assign_all_cmd) + au_sub.add_argument("iss", type=str, help="Issuer") + au_sub.add_argument("sub", type=str, help="Subject ID") + + ap_sub = subparsers.add_parser("add-grant-permissions", help="Adds permission(s) to an existing grant.") + ap_sub.set_defaults(func=add_grant_permissions_cmd) + ap_sub.add_argument("grant_id", type=int, help="Grant ID (use `bento_authz list` to see grants)") + ap_sub.add_argument("permissions", type=str, nargs="+", help="Permissions") + + sp_sub = subparsers.add_parser("set-grant-permissions", help="Edits a grant to have a new set of permissions.") + sp_sub.set_defaults(func=set_grant_permissions_cmd) + sp_sub.add_argument("grant_id", type=int, help="Grant ID (use `bento_authz list` to see grants)") + sp_sub.add_argument("permissions", type=str, nargs="+", help="Permissions") + + # ------------------------------------------------------------------------------------------------------------------ p_args = parser.parse_args(args) if not getattr(p_args, "func", None): diff --git a/bento_authorization_service/db.py b/bento_authorization_service/db.py index a0cd756..0b24b1f 100644 --- a/bento_authorization_service/db.py +++ b/bento_authorization_service/db.py @@ -195,6 +195,23 @@ async def create_grant(self, grant: GrantModel) -> int | None: return res + async def add_grant_permissions( + self, grant_id: int, permissions: frozenset[str], existing_conn: asyncpg.Connection | None = None + ) -> None: + conn: asyncpg.Connection + async with self.connect(existing_conn) as conn: + await conn.executemany( + 'INSERT INTO grant_permissions ("grant", "permission") VALUES ($1, $2) ON CONFLICT DO NOTHING', + [(grant_id, p) for p in permissions], + ) + + async def set_grant_permissions(self, grant_id: int, permissions: frozenset[str]) -> None: + conn: asyncpg.Connection + async with self.connect() as conn: + async with conn.transaction(): + await conn.execute('DELETE FROM grant_permissions WHERE "grant" = $1', grant_id) + await self.add_grant_permissions(grant_id, permissions, existing_conn=conn) + async def delete_grant(self, grant_id: int) -> None: conn: asyncpg.Connection async with self.connect() as conn: diff --git a/pyproject.toml b/pyproject.toml index 0f9ec72..e858d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bento-authorization-service" -version = "0.5.1" +version = "0.6.0" description = "Permissions and authorization service for the Bento platform." authors = ["David Lougheed "] license = "LGPL-3.0-only" diff --git a/tests/test_base_db.py b/tests/test_base_db.py deleted file mode 100644 index 5e79ea6..0000000 --- a/tests/test_base_db.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest -from bento_authorization_service.db import Database - - -# noinspection PyUnusedLocal -@pytest.mark.asyncio -async def test_db_open_close(db: Database, db_cleanup): - await db.close() - assert db._pool is None - - # duplicate request: should be idempotent - await db.close() - assert db._pool is None - - # should not be able to connect - async with db.connect(): - assert db._pool is not None # Connection auto-initialized - - # try re-opening - await db.initialize() - assert db._pool is not None - old_pool = db._pool - - # duplicate request: should be idempotent - await db.initialize() - assert db._pool == old_pool # same instance diff --git a/tests/test_cli.py b/tests/test_cli.py index 19f53ee..40c945c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,6 @@ import pytest -from bento_lib.auth.permissions import PERMISSIONS +from bento_lib.auth.permissions import PERMISSIONS, P_QUERY_DATA, P_INGEST_DATA from bento_authorization_service import cli from bento_authorization_service.config import get_config @@ -163,6 +163,41 @@ async def test_cli_get_grant(capsys, db: Database, db_cleanup): assert r == 1 +# noinspection PyUnusedLocal +@pytest.mark.asyncio +async def test_cli_add_grant_permissions(capsys, db: Database, db_cleanup): + existing_grant = (await db.get_grants())[0] # default: view:permissions, edit:permissions + + r = await cli.main(["add-grant-permissions", str(existing_grant.id), str(P_QUERY_DATA), str(P_INGEST_DATA)]) + assert r == 0 + + assert (await db.get_grant(existing_grant.id)).permissions == existing_grant.permissions.union( + frozenset({P_QUERY_DATA, P_INGEST_DATA}) + ) + + r = await cli.main(["add-grant-permissions", str(existing_grant.id), str(P_QUERY_DATA), str(P_INGEST_DATA)]) + assert r == 0 + captured = capsys.readouterr() + assert captured.err.startswith(f"Grant {existing_grant.id} already has permissions") + + r = await cli.main(["add-grant-permissions", "0", str(P_QUERY_DATA), str(P_INGEST_DATA)]) + assert r == 1 + + +# noinspection PyUnusedLocal +@pytest.mark.asyncio +async def test_cli_set_grant_permissions(capsys, db: Database, db_cleanup): + existing_grant = (await db.get_grants())[0] # default: view:permissions, edit:permissions + + r = await cli.main(["set-grant-permissions", str(existing_grant.id), str(P_QUERY_DATA), str(P_INGEST_DATA)]) + assert r == 0 + + assert (await db.get_grant(existing_grant.id)).permissions == frozenset({P_QUERY_DATA, P_INGEST_DATA}) + + r = await cli.main(["set-grant-permissions", "0", str(P_QUERY_DATA), str(P_INGEST_DATA)]) + assert r == 1 + + # noinspection PyUnusedLocal @pytest.mark.asyncio async def test_cli_delete_bad_entity(db: Database, db_cleanup): diff --git a/tests/test_grants.py b/tests/test_grants.py index cad8926..2dcf1d6 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -1,6 +1,7 @@ import json import pytest +from bento_lib.auth import permissions from fastapi import status from fastapi.testclient import TestClient from pydantic import ValidationError @@ -62,6 +63,37 @@ def test_bad_grant_permission_length(): GrantModel(**{**sd.TEST_GRANT_DAVID_PROJECT_1_QUERY_DATA.model_dump(), "permissions": frozenset()}) +# noinspection PyUnusedLocal +@pytest.mark.asyncio +async def test_grant_create(db: Database, db_cleanup): + id_ = await db.create_grant(sd.TEST_GRANT_DAVID_PROJECT_1_QUERY_DATA) + assert (await db.get_grant(id_)) is not None + + +# noinspection PyUnusedLocal +@pytest.mark.asyncio +async def test_grant_add_permissions(db: Database, db_cleanup): + id_ = await db.create_grant(sd.TEST_GRANT_DAVID_PROJECT_1_QUERY_DATA) + assert (await db.get_grant(id_)).permissions == frozenset({permissions.P_QUERY_DATA}) + + await db.add_grant_permissions(id_, frozenset({permissions.P_EDIT_PERMISSIONS, permissions.P_INGEST_DATA})) + assert (await db.get_grant(id_)).permissions == frozenset( + {permissions.P_QUERY_DATA, permissions.P_EDIT_PERMISSIONS, permissions.P_INGEST_DATA} + ) + + +# noinspection PyUnusedLocal +@pytest.mark.asyncio +async def test_grant_set_permissions(db: Database, db_cleanup): + id_ = await db.create_grant(sd.TEST_GRANT_DAVID_PROJECT_1_QUERY_DATA) + assert (await db.get_grant(id_)).permissions == frozenset({permissions.P_QUERY_DATA}) + + await db.set_grant_permissions(id_, frozenset({permissions.P_EDIT_PERMISSIONS, permissions.P_INGEST_DATA})) + assert (await db.get_grant(id_)).permissions == frozenset( + {permissions.P_EDIT_PERMISSIONS, permissions.P_INGEST_DATA} + ) + + # noinspection PyUnusedLocal @pytest.mark.asyncio async def test_grant_endpoints_create(test_client: TestClient, db: Database, db_cleanup):