Skip to content

Commit 69f3346

Browse files
authored
Support for Groups as Principal (#1574)
* Add principal kind for GROUP * Add group members table * Add pluggable group membership service * Add API endpoints to support groups * Add groups-related models * Fix database migration
1 parent 026b29f commit 69f3346

File tree

11 files changed

+1452
-1
lines changed

11 files changed

+1452
-1
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Add groups support
3+
4+
Revision ID: a1b2c3d4e5f6
5+
Revises: be76e22dd71a
6+
Create Date: 2025-11-25 15:00:00.000000+00:00
7+
"""
8+
# pylint: disable=no-member, invalid-name, missing-function-docstring, unused-import, no-name-in-module
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "a1b2c3d4e5f6"
16+
down_revision = "95732205ad12"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade():
22+
op.execute("ALTER TYPE principalkind ADD VALUE 'GROUP'")
23+
24+
op.create_table(
25+
"group_members",
26+
sa.Column(
27+
"group_id",
28+
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
29+
nullable=False,
30+
),
31+
sa.Column(
32+
"member_id",
33+
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
34+
nullable=False,
35+
),
36+
sa.Column(
37+
"added_at",
38+
sa.DateTime(timezone=True),
39+
nullable=False,
40+
server_default=sa.text("NOW()"),
41+
),
42+
sa.ForeignKeyConstraint(
43+
["group_id"],
44+
["users.id"],
45+
name="fk_group_members_group_id",
46+
ondelete="CASCADE",
47+
),
48+
sa.ForeignKeyConstraint(
49+
["member_id"],
50+
["users.id"],
51+
name="fk_group_members_member_id",
52+
ondelete="CASCADE",
53+
),
54+
sa.PrimaryKeyConstraint("group_id", "member_id"),
55+
sa.CheckConstraint(
56+
"group_id != member_id",
57+
name="chk_no_self_membership",
58+
),
59+
)
60+
61+
op.create_index(
62+
"idx_group_members_group_id",
63+
"group_members",
64+
["group_id"],
65+
)
66+
op.create_index(
67+
"idx_group_members_member_id",
68+
"group_members",
69+
["member_id"],
70+
)
71+
72+
73+
def downgrade():
74+
op.drop_index("idx_group_members_member_id", table_name="group_members")
75+
op.drop_index("idx_group_members_group_id", table_name="group_members")
76+
77+
op.drop_table("group_members")
78+
79+
# Postgres doesn't support removing enum values easily
80+
# Users will need to manually handle enum cleanup if needed
81+
# Or recreate the enum without GROUP value
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""
2+
Group management APIs.
3+
"""
4+
5+
from typing import List
6+
7+
from fastapi import Depends, HTTPException
8+
from sqlalchemy import select
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
from datajunction_server.database.group_member import GroupMember
12+
from datajunction_server.database.user import OAuthProvider, PrincipalKind, User
13+
from datajunction_server.errors import DJAlreadyExistsException, DJDoesNotExistException
14+
from datajunction_server.internal.access.authentication.http import SecureAPIRouter
15+
from datajunction_server.models.group import GroupOutput
16+
from datajunction_server.models.user import UserOutput
17+
from datajunction_server.utils import get_session, get_settings
18+
19+
settings = get_settings()
20+
router = SecureAPIRouter(tags=["groups"])
21+
22+
23+
@router.post("/groups/", response_model=GroupOutput, status_code=201)
24+
async def register_group(
25+
username: str,
26+
email: str | None = None,
27+
name: str | None = None,
28+
*,
29+
session: AsyncSession = Depends(get_session),
30+
) -> User:
31+
"""
32+
Register a group in DJ.
33+
34+
This makes the group available for assignment as a node owner.
35+
Group membership can be managed via the membership endpoints (Postgres provider)
36+
or resolved externally (LDAP, etc.).
37+
38+
Args:
39+
username: Unique identifier for the group (e.g., 'eng-team')
40+
email: Optional email for the group
41+
name: Display name (defaults to username)
42+
"""
43+
existing = await User.get_by_username(session, username)
44+
if existing:
45+
raise DJAlreadyExistsException(message=f"Group {username} already exists")
46+
47+
# Create group
48+
group = User(
49+
username=username,
50+
email=email,
51+
name=name or username,
52+
kind=PrincipalKind.GROUP,
53+
oauth_provider=OAuthProvider.BASIC,
54+
)
55+
session.add(group)
56+
await session.commit()
57+
await session.refresh(group)
58+
return group
59+
60+
61+
@router.get("/groups/", response_model=List[GroupOutput])
62+
async def list_groups(
63+
*,
64+
session: AsyncSession = Depends(get_session),
65+
) -> List[User]:
66+
"""
67+
List all registered groups.
68+
"""
69+
statement = (
70+
select(User).where(User.kind == PrincipalKind.GROUP).order_by(User.username)
71+
)
72+
result = await session.execute(statement)
73+
return list(result.scalars().all())
74+
75+
76+
@router.get("/groups/{group_name}", response_model=GroupOutput)
77+
async def get_group(
78+
group_name: str,
79+
*,
80+
session: AsyncSession = Depends(get_session),
81+
) -> User:
82+
"""
83+
Get a group by name.
84+
"""
85+
group = await User.get_by_username(session, group_name)
86+
if not group or group.kind != PrincipalKind.GROUP:
87+
raise HTTPException(status_code=404, detail=f"Group {group_name} not found")
88+
89+
return group
90+
91+
92+
@router.post("/groups/{group_name}/members/", status_code=201)
93+
async def add_group_member(
94+
group_name: str,
95+
member_username: str,
96+
*,
97+
session: AsyncSession = Depends(get_session),
98+
) -> dict:
99+
"""
100+
Add a member to a group (Postgres provider only).
101+
102+
For external providers, membership is managed externally and this endpoint is disabled.
103+
"""
104+
if settings.group_membership_provider != "postgres":
105+
raise HTTPException(
106+
status_code=400,
107+
detail=f"Membership management not supported for provider: {settings.group_membership_provider}",
108+
)
109+
110+
# Verify group exists
111+
group = await User.get_by_username(session, group_name)
112+
if not group or group.kind != PrincipalKind.GROUP:
113+
raise DJDoesNotExistException(message=f"Group {group_name} not found")
114+
115+
# Verify member exists
116+
member = await User.get_by_username(session, member_username)
117+
if not member:
118+
raise DJDoesNotExistException(message=f"User {member_username} not found")
119+
120+
# Check if already a member
121+
existing = await session.execute(
122+
select(GroupMember).where(
123+
GroupMember.group_id == group.id,
124+
GroupMember.member_id == member.id,
125+
),
126+
)
127+
if existing.scalar_one_or_none():
128+
raise HTTPException(
129+
status_code=409,
130+
detail=f"{member_username} is already a member of {group_name}",
131+
)
132+
133+
# Add membership
134+
membership = GroupMember(
135+
group_id=group.id,
136+
member_id=member.id,
137+
)
138+
session.add(membership)
139+
await session.commit()
140+
141+
return {"message": f"Added {member_username} to {group_name}"}
142+
143+
144+
@router.delete("/groups/{group_name}/members/{member_username}", status_code=204)
145+
async def remove_group_member(
146+
group_name: str,
147+
member_username: str,
148+
*,
149+
session: AsyncSession = Depends(get_session),
150+
) -> None:
151+
"""
152+
Remove a member from a group (Postgres provider only).
153+
"""
154+
if settings.group_membership_provider != "postgres":
155+
raise HTTPException(
156+
status_code=400,
157+
detail=f"Membership management not supported for provider: {settings.group_membership_provider}",
158+
)
159+
160+
# Verify group and member exist
161+
group = await User.get_by_username(session, group_name)
162+
if not group or group.kind != PrincipalKind.GROUP:
163+
raise DJDoesNotExistException(message=f"Group {group_name} not found")
164+
165+
member = await User.get_by_username(session, member_username)
166+
if not member:
167+
raise DJDoesNotExistException(message=f"User {member_username} not found")
168+
169+
# Remove membership
170+
result = await session.execute(
171+
select(GroupMember).where(
172+
GroupMember.group_id == group.id,
173+
GroupMember.member_id == member.id,
174+
),
175+
)
176+
membership = result.scalar_one_or_none()
177+
178+
if not membership:
179+
raise HTTPException(
180+
status_code=404,
181+
detail=f"{member_username} is not a member of {group_name}",
182+
)
183+
184+
await session.delete(membership)
185+
await session.commit()
186+
187+
188+
@router.get("/groups/{group_name}/members/", response_model=List[UserOutput])
189+
async def list_group_members(
190+
group_name: str,
191+
*,
192+
session: AsyncSession = Depends(get_session),
193+
) -> List[User]:
194+
"""
195+
List members of a group.
196+
197+
For Postgres provider: queries group_members table.
198+
For external providers: returns empty (membership resolved externally).
199+
"""
200+
# Verify group exists
201+
group = await User.get_by_username(session, group_name)
202+
if not group or group.kind != PrincipalKind.GROUP:
203+
raise HTTPException(status_code=404, detail=f"Group {group_name} not found")
204+
205+
# Only return members for postgres provider
206+
if settings.group_membership_provider != "postgres":
207+
return []
208+
209+
# Query members
210+
statement = (
211+
select(User)
212+
.join(GroupMember, GroupMember.member_id == User.id)
213+
.where(GroupMember.group_id == group.id)
214+
.order_by(User.username)
215+
)
216+
result = await session.execute(statement)
217+
return list(result.scalars().all())

datajunction-server/datajunction_server/api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
dimensions,
2929
djsql,
3030
engines,
31+
groups,
3132
health,
3233
hierarchies,
3334
history,
@@ -121,6 +122,7 @@ def configure_app(app: FastAPI) -> None:
121122
app.include_router(graphql_app, prefix="/graphql")
122123
app.include_router(whoami.router)
123124
app.include_router(users.router)
125+
app.include_router(groups.router)
124126
app.include_router(basic.router)
125127
app.include_router(notifications.router)
126128
app.include_router(service_account.secure_router)

datajunction-server/datajunction_server/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ class Settings(BaseSettings): # pragma: no cover
133133
# Interval in seconds for which to expire service account tokens
134134
service_account_token_expire: int = 3600 * 24 * 30
135135

136+
# Group membership provider
137+
# Options: "postgres" (uses group_members table), "static" (no membership),
138+
# or a custom implementation of the GroupMembershipProvider interface
139+
group_membership_provider: str = "postgres"
140+
136141
# Interval in seconds with which to expire caching of any indexes
137142
index_cache_expire: int = 60
138143

datajunction-server/datajunction_server/database/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"Deployment",
1010
"DimensionLink",
1111
"Engine",
12+
"GroupMember",
1213
"History",
1314
"Node",
1415
"NodeNamespace",
@@ -28,6 +29,7 @@
2829
from datajunction_server.database.database import Database, Table
2930
from datajunction_server.database.dimensionlink import DimensionLink
3031
from datajunction_server.database.engine import Engine
32+
from datajunction_server.database.group_member import GroupMember
3133
from datajunction_server.database.measure import Measure
3234
from datajunction_server.database.namespace import NodeNamespace
3335
from datajunction_server.database.node import Node, NodeRevision

0 commit comments

Comments
 (0)