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): class Group(Base):
__tablename__ = "group" __tablename__ = "group"
id = Column(Integer, primary_key=True) 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")) 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") user_rel = relationship("User", secondary="user_groups", back_populates="group_rel")
org_rel = relationship("Organisation", back_populates="group_rel") org_rel = relationship("Organisation", back_populates="group_rel")

View file

@ -8,12 +8,14 @@ Exports:
from typing import Annotated from typing import Annotated
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from src.iam.schemas import IAMCAoRRequest
from src.service.models import Service from src.service.models import Service
from src.database import db_dependency from src.database import db_dependency
from src.exceptions import UnauthorizedException from src.exceptions import UnauthorizedException
from src.utils import send_email, generate_jwt 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 from fastapi import Request, Depends
@ -64,3 +66,49 @@ async def send_user_group_invitation(
subject=subject, subject=subject,
body=body, 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.schemas import ContactAddress
from src.contact.exceptions import ContactNotFoundException from src.contact.exceptions import ContactNotFoundException
from src.database import db_dependency 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.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0
from src.user.dependencies import ( from src.user.dependencies import (
user_model_body_dependency, 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 # Adds currently logged-in user to org users list and sets them as root_user
org_model.user_rel.append(user_model) org_model.user_rel.append(user_model)
org_model.root_user_rel = 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 [ for contact_type in [
"billing_contact_id", "billing_contact_id",
"security_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") raise ConflictException(message="User already a part of this organisation")
org_model.user_rel.append(user_model) org_model.user_rel.append(user_model)
db.flush() db.flush()
await assign_default_user_group(db=db, org_model=org_model, user_model=user_model)
response = { response = {
"organisation": org_model, "organisation": org_model,
"users": [{"id": user.id, "email": user.email} for user in org_model.user_rel], "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 fastapi import APIRouter, status, BackgroundTasks
from src.iam.service import assign_default_user_group
from src.organisation.exceptions import OrgNotFoundException from src.organisation.exceptions import OrgNotFoundException
from src.user.schemas import ( from src.user.schemas import (
UserResponse, UserResponse,
@ -199,6 +200,7 @@ async def accept_invitation(
org_model.user_rel.append(user_model) org_model.user_rel.append(user_model)
db.flush() db.flush()
await assign_default_user_group(db=db, org_model=org_model, user_model=user_model)
response = { response = {
"organisation": org_model, "organisation": org_model,

View file

@ -283,6 +283,15 @@ async def test_post_group_conflict(default_client: AsyncClient):
assert resp.status_code == 409 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 @pytest.mark.anyio
async def test_put_group_perm_success(default_client: AsyncClient): async def test_put_group_perm_success(default_client: AsyncClient):
resp = await default_client.put( resp = await default_client.put(