From dad23733e89bf9e78a12861dbb21f7ceeb8da9e0 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 15 Jun 2026 11:10:02 +0100 Subject: [PATCH 1/3] feat: group name unique per org Instead of group names being wholly unique (enforced by the db), group names are unique within the org (enforced by endpoint logic). --- .../2026-06-15_group_name_unique_per_org.py | 34 +++++++++++++++++++ src/iam/models.py | 10 +++++- test/test_iam.py | 9 +++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .alembic/versions/2026-06-15_group_name_unique_per_org.py diff --git a/.alembic/versions/2026-06-15_group_name_unique_per_org.py b/.alembic/versions/2026-06-15_group_name_unique_per_org.py new file mode 100644 index 0000000..9a8e7f9 --- /dev/null +++ b/.alembic/versions/2026-06-15_group_name_unique_per_org.py @@ -0,0 +1,34 @@ +"""group name unique per org + +Revision ID: 98e20aae555c +Revises: b6c8614ef799 +Create Date: 2026-06-15 11:05:16.673658 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '98e20aae555c' +down_revision: Union[str, Sequence[str], None] = 'b6c8614ef799' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('group_name_key'), 'group', type_='unique') + op.create_unique_constraint('uniq_group_name_org_id', 'group', ['name', 'org_id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uniq_group_name_org_id', 'group', type_='unique') + op.create_unique_constraint(op.f('group_name_key'), 'group', ['name'], postgresql_nulls_not_distinct=False) + # ### end Alembic commands ### diff --git a/src/iam/models.py b/src/iam/models.py index 0087abc..5e9b821 100644 --- a/src/iam/models.py +++ b/src/iam/models.py @@ -56,10 +56,18 @@ class Permission(Base): class Group(Base): __tablename__ = "group" id = Column(Integer, primary_key=True) - name = Column(String, nullable=False, unique=True) + name = Column(String, nullable=False) org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE")) + __table_args__ = ( + UniqueConstraint( + "name", + "org_id", + name="uniq_group_name_org_id", + ), + ) + user_rel = relationship("User", secondary="user_groups", back_populates="group_rel") org_rel = relationship("Organisation", back_populates="group_rel") diff --git a/test/test_iam.py b/test/test_iam.py index 3980eea..b9e597c 100644 --- a/test/test_iam.py +++ b/test/test_iam.py @@ -283,6 +283,15 @@ async def test_post_group_conflict(default_client: AsyncClient): assert resp.status_code == 409 +@pytest.mark.anyio +async def test_post_group_non_conflict(default_client: AsyncClient): + resp = await default_client.post( + "/iam/group", json={"organisation_id": 2, "name": "Org One Group"} + ) + + assert resp.status_code == 201 + + @pytest.mark.anyio async def test_put_group_perm_success(default_client: AsyncClient): resp = await default_client.put( From 09d2fbafdce604df846eb3651f1271cfce188fa3 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 15 Jun 2026 11:26:22 +0100 Subject: [PATCH 2/3] feat: default iam groups on org create Root user is given the `Default Users` and `Root User` permission groups on org creation. --- src/iam/service.py | 50 +++++++++++++++++++++++++++++++++++++- src/organisation/router.py | 5 ++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/iam/service.py b/src/iam/service.py index e7d6699..056b39c 100644 --- a/src/iam/service.py +++ b/src/iam/service.py @@ -8,12 +8,14 @@ Exports: from typing import Annotated from datetime import datetime, timedelta, timezone -from src.iam.schemas import IAMCAoRRequest from src.service.models import Service from src.database import db_dependency from src.exceptions import UnauthorizedException from src.utils import send_email, generate_jwt +from src.iam.schemas import IAMCAoRRequest +from src.iam.models import Group + from fastapi import Request, Depends @@ -64,3 +66,49 @@ async def send_user_group_invitation( subject=subject, body=body, ) + + +async def create_default_user_group(db: db_dependency, org_model): + new_group = Group(name="Default Users", org_id=org_model.id) + db.add(new_group) + db.flush() + # Grant default permissions here + db.flush() + return new_group + + +async def assign_default_user_group(db: db_dependency, org_model, user_model): + group_model = None + for group in org_model.group_rel: + if group.name == "Default Users": + group_model = group + break + + if group_model is None: + group_model = await create_default_user_group(db=db, org_model=org_model) + + user_model.group_rel.append(group_model) + db.flush() + + +async def create_default_root_group(db: db_dependency, org_model): + new_group = Group(name="Root User", org_id=org_model.id) + db.add(new_group) + db.flush() + # Grant default permissions here + db.flush() + return new_group + + +async def assign_default_root_group(db: db_dependency, org_model, user_model): + group_model = None + for group in org_model.group_rel: + if group.name == "Root User": + group_model = group + break + + if group_model is None: + group_model = await create_default_root_group(db=db, org_model=org_model) + + user_model.group_rel.append(group_model) + db.flush() diff --git a/src/organisation/router.py b/src/organisation/router.py index 3be0a2b..ce8aaf2 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -33,6 +33,7 @@ from src.contact.models import Contact from src.contact.schemas import ContactAddress from src.contact.exceptions import ContactNotFoundException from src.database import db_dependency +from src.iam.service import assign_default_user_group, assign_default_root_group from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 from src.user.dependencies import ( user_model_body_dependency, @@ -183,6 +184,10 @@ async def create_org( # Adds currently logged-in user to org users list and sets them as root_user org_model.user_rel.append(user_model) org_model.root_user_rel = user_model + + # Creates default user and default root IAM groups and assigns them + await assign_default_user_group(db, org_model, user_model) + await assign_default_root_group(db, org_model, user_model) for contact_type in [ "billing_contact_id", "security_contact_id", From 3433ba39ee93b3330b8c9d551860e93a45c438f1 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 15 Jun 2026 11:35:01 +0100 Subject: [PATCH 3/3] feat: default iam group on org join Users joining an org are given the `Default User` IAM permission group automatically. --- src/organisation/router.py | 1 + src/user/router.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/organisation/router.py b/src/organisation/router.py index ce8aaf2..f0f212a 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -352,6 +352,7 @@ async def add_user_to_org( raise ConflictException(message="User already a part of this organisation") org_model.user_rel.append(user_model) db.flush() + await assign_default_user_group(db=db, org_model=org_model, user_model=user_model) response = { "organisation": org_model, "users": [{"id": user.id, "email": user.email} for user in org_model.user_rel], diff --git a/src/user/router.py b/src/user/router.py index a561be4..c4b5379 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -10,6 +10,7 @@ Endpoints: from fastapi import APIRouter, status, BackgroundTasks +from src.iam.service import assign_default_user_group from src.organisation.exceptions import OrgNotFoundException from src.user.schemas import ( UserResponse, @@ -199,6 +200,7 @@ async def accept_invitation( org_model.user_rel.append(user_model) db.flush() + await assign_default_user_group(db=db, org_model=org_model, user_model=user_model) response = { "organisation": org_model,