Compare commits

..

3 commits

Author SHA1 Message Date
3433ba39ee feat: default iam group on org join
All checks were successful
ci / lint_and_test (push) Successful in 15s
Users joining an org are given the `Default User` IAM permission group automatically.
2026-06-15 11:35:01 +01:00
09d2fbafdc feat: default iam groups on org create
Root user is given the `Default Users` and `Root User` permission groups on org creation.
2026-06-15 11:26:22 +01:00
dad23733e8 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 11:10:02 +01:00
6 changed files with 109 additions and 2 deletions

View file

@ -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 ###

View file

@ -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")

View file

@ -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()

View file

@ -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",
@ -347,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],

View file

@ -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,

View file

@ -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(