Skip to content

Commit fb0c2c1

Browse files
authored
feat: limit project group creation to admins (#1331)
Make it so that (if configured) admins are the only one allowed to create projects and groups. By default we have the old behavior so that everyone who is logged in can create a group or project. The limits are applied in addition to all other authorization schemes and regardless of where the project will be created (username or group).
1 parent 1c50f11 commit fb0c2c1

14 files changed

Lines changed: 689 additions & 78 deletions

File tree

bases/renku_data_services/data_api/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
242242
url_prefix=url_prefix,
243243
platform_repo=dm.platform_repo,
244244
authenticator=dm.authenticator,
245+
authz=dm.authz,
245246
)
246247
platform_redirects = PlatformUrlRedirectBP(
247248
name="platform_redirects",

components/renku_data_services/authz/authz.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
CheckPermissionItem,
4242
Member,
4343
MembershipChange,
44+
PlatformRole,
4445
Role,
4546
Scope,
4647
Visibility,
@@ -69,6 +70,7 @@
6970
ProjectNamespace,
7071
UserNamespace,
7172
)
73+
from renku_data_services.platform.models import AuthzFlag
7274
from renku_data_services.project.models import DeletedProject, Project, ProjectUpdate
7375
from renku_data_services.users.models import DeletedUser, UserInfo, UserInfoUpdate
7476

@@ -1477,6 +1479,7 @@ async def _get_admin_user_ids(self) -> list[str]:
14771479
resource_type=platform.object_type,
14781480
optional_resource_id=platform.object_id,
14791481
optional_subject_filter=sub_filter,
1482+
optional_relation=_Relation.admin.value,
14801483
)
14811484
existing_admins: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
14821485
ReadRelationshipsRequest(
@@ -2396,3 +2399,89 @@ async def _remove_data_connector_to_project_link(
23962399
updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
23972400
)
23982401
return _AuthzChange(apply=apply, undo=undo)
2402+
2403+
async def _relationship_exists(
2404+
self,
2405+
resource_type: str,
2406+
id: ULID | int | str,
2407+
relation: str,
2408+
subject: SubjectFilter,
2409+
zed_token: ZedToken | None = None,
2410+
) -> tuple[bool, ZedToken | None]:
2411+
"""If the filter conditions match multiple relationships then True is returned."""
2412+
2413+
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
2414+
req = ReadRelationshipsRequest(
2415+
consistency=consistency,
2416+
relationship_filter=RelationshipFilter(
2417+
resource_type=resource_type,
2418+
optional_relation=relation,
2419+
optional_resource_id=str(id),
2420+
optional_subject_filter=subject,
2421+
),
2422+
optional_limit=1,
2423+
)
2424+
2425+
async for res in self.client.ReadRelationships(req):
2426+
return True, res.read_at
2427+
return False, zed_token
2428+
2429+
async def group_creation_allowed(self, zed_token: ZedToken | None = None) -> tuple[AuthzFlag, ZedToken | None]:
2430+
"""Indicates whether users are allowed to create groups."""
2431+
platform = _AuthzConverter.platform()
2432+
all_users = _AuthzConverter.all_users()
2433+
groups_allowed, new_zed_token = await self._relationship_exists(
2434+
resource_type=platform.object_type,
2435+
id=platform.object_id,
2436+
relation=PlatformRole.group_creator.value,
2437+
subject=SubjectFilter(subject_type=ResourceType.user.value, optional_subject_id=all_users.object_id),
2438+
zed_token=zed_token,
2439+
)
2440+
return AuthzFlag.registered_users if groups_allowed else AuthzFlag.only_admins, new_zed_token
2441+
2442+
async def project_creation_allowed(self, zed_token: ZedToken | None = None) -> tuple[AuthzFlag, ZedToken | None]:
2443+
"""Indicates whether users are allowed to create projects."""
2444+
platform = _AuthzConverter.platform()
2445+
all_users = _AuthzConverter.all_users()
2446+
projects_allowed, new_zed_token = await self._relationship_exists(
2447+
resource_type=platform.object_type,
2448+
id=platform.object_id,
2449+
relation=PlatformRole.project_creator.value,
2450+
subject=SubjectFilter(subject_type=ResourceType.user.value, optional_subject_id=all_users.object_id),
2451+
zed_token=zed_token,
2452+
)
2453+
return AuthzFlag.registered_users if projects_allowed else AuthzFlag.only_admins, new_zed_token
2454+
2455+
async def set_project_creation_permission(self, allowed: AuthzFlag) -> ZedToken:
2456+
"""Controls whether any user or only admins are able to create projects."""
2457+
platform = _AuthzConverter.platform()
2458+
all_users = _AuthzConverter.all_users()
2459+
update = RelationshipUpdate(
2460+
operation=RelationshipUpdate.OPERATION_DELETE
2461+
if allowed == AuthzFlag.only_admins
2462+
else RelationshipUpdate.OPERATION_TOUCH,
2463+
relationship=Relationship(
2464+
resource=platform,
2465+
relation=PlatformRole.project_creator.value,
2466+
subject=SubjectReference(object=all_users),
2467+
),
2468+
)
2469+
res = await self.client.WriteRelationships(WriteRelationshipsRequest(updates=[update]))
2470+
return cast(ZedToken, res.written_at)
2471+
2472+
async def set_group_creation_permission(self, allowed: AuthzFlag) -> ZedToken:
2473+
"""Controls whether any user or only admins are able to create groups."""
2474+
platform = _AuthzConverter.platform()
2475+
all_users = _AuthzConverter.all_users()
2476+
update = RelationshipUpdate(
2477+
operation=RelationshipUpdate.OPERATION_DELETE
2478+
if allowed == AuthzFlag.only_admins
2479+
else RelationshipUpdate.OPERATION_TOUCH,
2480+
relationship=Relationship(
2481+
resource=platform,
2482+
relation=PlatformRole.group_creator.value,
2483+
subject=SubjectReference(object=all_users),
2484+
),
2485+
)
2486+
res = await self.client.WriteRelationships(WriteRelationshipsRequest(updates=[update]))
2487+
return cast(ZedToken, res.written_at)

components/renku_data_services/authz/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Models for authorization."""
22

33
from dataclasses import dataclass
4-
from enum import Enum
4+
from enum import Enum, StrEnum
55

66
from ulid import ULID
77

@@ -44,6 +44,13 @@ def to_group_role(self) -> GroupRole:
4444
raise errors.ProgrammingError(message=f"Could not convert role {self} into a group role")
4545

4646

47+
class PlatformRole(StrEnum):
48+
"""Roles specific to the platform resource in Authzed."""
49+
50+
project_creator = "project_creator"
51+
group_creator = "group_creator"
52+
53+
4754
class Scope(Enum):
4855
"""Types of permissions - i.e. scope."""
4956

@@ -59,6 +66,8 @@ class Scope(Enum):
5966
EXCLUSIVE_EDITOR = "exclusive_editor"
6067
EXCLUSIVE_OWNER = "exclusive_owner"
6168
DIRECT_MEMBER = "direct_member"
69+
CREATE_GROUPS = "create_groups"
70+
CREATE_PROJECTS = "create_projects"
6271

6372

6473
@dataclass

components/renku_data_services/authz/schemas.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,3 +981,149 @@ def generate_v4(public_project_ids: Iterable[str]) -> AuthzSchemaMigration:
981981
WriteSchemaRequest(schema=_v9),
982982
],
983983
)
984+
985+
_v11 = """\
986+
definition user {}
987+
988+
definition group {
989+
relation group_platform: platform
990+
relation owner: user
991+
relation editor: user
992+
relation viewer: user
993+
relation public_viewer: user:* | anonymous_user:*
994+
permission read = public_viewer + read_children
995+
permission read_children = viewer + write
996+
permission write = editor + delete
997+
permission change_membership = delete
998+
permission delete = owner + group_platform->is_admin
999+
permission non_public_read = owner + editor + viewer - public_viewer
1000+
permission exclusive_owner = owner
1001+
permission exclusive_editor = editor
1002+
permission exclusive_member = viewer + editor + owner
1003+
permission direct_member = owner + editor + viewer
1004+
}
1005+
1006+
definition user_namespace {
1007+
relation user_namespace_platform: platform
1008+
relation owner: user
1009+
relation public_viewer: user:* | anonymous_user:*
1010+
permission read = public_viewer + read_children
1011+
permission read_children = delete
1012+
permission write = delete
1013+
permission delete = owner + user_namespace_platform->is_admin
1014+
permission non_public_read = owner - public_viewer
1015+
permission exclusive_owner = owner
1016+
permission exclusive_member = owner
1017+
permission direct_member = owner
1018+
}
1019+
1020+
definition anonymous_user {}
1021+
1022+
definition platform {
1023+
relation admin: user
1024+
relation project_creator: user:*
1025+
relation group_creator: user:*
1026+
permission is_admin = admin
1027+
permission create_projects = is_admin + project_creator
1028+
permission create_groups = is_admin + group_creator
1029+
}
1030+
1031+
definition project {
1032+
relation project_platform: platform
1033+
relation project_namespace: user_namespace | group
1034+
relation owner: user
1035+
relation editor: user
1036+
relation viewer: user
1037+
relation public_viewer: user:* | anonymous_user:*
1038+
permission read = public_viewer + read_children
1039+
permission read_children = viewer + write + project_namespace->read_children
1040+
permission write = editor + delete
1041+
permission change_membership = delete
1042+
permission delete = owner + project_platform->is_admin + project_namespace->delete
1043+
permission non_public_read = owner + editor + viewer + project_namespace->read_children - public_viewer
1044+
permission exclusive_owner = owner + project_namespace->exclusive_owner
1045+
permission exclusive_editor = editor
1046+
permission exclusive_member = owner + editor + viewer + project_namespace->exclusive_member
1047+
permission direct_member = owner + editor + viewer
1048+
}
1049+
1050+
definition data_connector {
1051+
relation data_connector_platform: platform
1052+
relation data_connector_namespace: user_namespace | group | project
1053+
relation linked_to: project
1054+
relation owner: user
1055+
relation editor: user
1056+
relation viewer: user
1057+
relation public_viewer: user:* | anonymous_user:*
1058+
permission read = public_viewer + viewer + write + data_connector_namespace->read_children
1059+
permission write = editor + delete
1060+
permission change_membership = delete
1061+
permission delete = owner + data_connector_platform->is_admin + data_connector_namespace->delete
1062+
permission non_public_read = owner + editor + viewer + data_connector_namespace->read_children - public_viewer
1063+
permission exclusive_owner = owner + data_connector_namespace->exclusive_owner
1064+
permission exclusive_editor = editor
1065+
permission exclusive_member = owner + editor + viewer + data_connector_namespace->exclusive_member
1066+
permission direct_member = owner + editor + viewer
1067+
}
1068+
1069+
definition resource_pool {
1070+
relation resource_pool_platform: platform
1071+
relation viewer: user
1072+
relation group_viewer: group
1073+
relation project_viewer: project
1074+
relation prohibited: user
1075+
relation public_viewer: user:* | anonymous_user:*
1076+
permission read = ( \
1077+
viewer \
1078+
+ group_viewer->direct_member\
1079+
+ project_viewer->direct_member \
1080+
+ public_viewer \
1081+
- prohibited \
1082+
) \
1083+
+ resource_pool_platform->is_admin
1084+
permission write = resource_pool_platform->is_admin
1085+
}"""
1086+
"""Adds the resource_pool definition for Authzed authorization."""
1087+
1088+
v11 = AuthzSchemaMigration(
1089+
up=[
1090+
WriteSchemaRequest(schema=_v11),
1091+
WriteRelationshipsRequest(
1092+
updates=[
1093+
RelationshipUpdate(
1094+
operation=RelationshipUpdate.OPERATION_TOUCH,
1095+
relationship=Relationship(
1096+
resource=_AuthzConverter.platform(),
1097+
relation="project_creator",
1098+
subject=SubjectReference(object=_AuthzConverter.all_users()),
1099+
),
1100+
),
1101+
RelationshipUpdate(
1102+
operation=RelationshipUpdate.OPERATION_TOUCH,
1103+
relationship=Relationship(
1104+
resource=_AuthzConverter.platform(),
1105+
relation="group_creator",
1106+
subject=SubjectReference(object=_AuthzConverter.all_users()),
1107+
),
1108+
),
1109+
]
1110+
),
1111+
],
1112+
down=[
1113+
DeleteRelationshipsRequest(
1114+
relationship_filter=RelationshipFilter(
1115+
resource_type=_AuthzConverter.platform().object_type,
1116+
optional_resource_id=_AuthzConverter.platform().object_id,
1117+
optional_relation="project_creator",
1118+
),
1119+
),
1120+
DeleteRelationshipsRequest(
1121+
relationship_filter=RelationshipFilter(
1122+
resource_type=_AuthzConverter.platform().object_type,
1123+
optional_resource_id=_AuthzConverter.platform().object_id,
1124+
optional_relation="group_creator",
1125+
),
1126+
),
1127+
WriteSchemaRequest(schema=_v10),
1128+
],
1129+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""upgrade authzed schema to v11
2+
3+
Revision ID: f2d4841e924b
4+
Revises: 65f8330c4d25
5+
Create Date: 2026-05-27 14:32:12.144808
6+
7+
"""
8+
9+
from renku_data_services.app_config import logging
10+
from renku_data_services.authz.config import AuthzConfig
11+
from renku_data_services.authz.schemas import v11
12+
13+
logger = logging.getLogger(__name__)
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "f2d4841e924b"
17+
down_revision = "65f8330c4d25"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
config = AuthzConfig.from_env()
24+
client = config.authz_client()
25+
responses = v11.upgrade(client)
26+
logger.info(
27+
f"Finished upgrading the Authz schema to version 11 in Alembic revision {revision}, response: {responses}"
28+
)
29+
30+
31+
def downgrade() -> None:
32+
config = AuthzConfig.from_env()
33+
client = config.authz_client()
34+
responses = v11.downgrade(client)
35+
logger.info(
36+
f"Finished downgrading the Authz schema from version 11 in Alembic revision {revision}, response: {responses}"
37+
)

components/renku_data_services/namespace/db.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import renku_data_services.base_models as base_models
2020
from renku_data_services import errors
2121
from renku_data_services.app_config import logging
22-
from renku_data_services.authz.authz import Authz, AuthzOperation, ResourceType
22+
from renku_data_services.authz.authz import Authz, AuthzOperation, ResourceType, _AuthzConverter
2323
from renku_data_services.authz.models import CheckPermissionItem, Member, MembershipChange, Role, Scope, UnsavedMember
2424
from renku_data_services.base_api.pagination import PaginationRequest, paginate_queries
2525
from renku_data_services.base_models.core import (
@@ -379,6 +379,15 @@ async def insert_group(
379379
raise errors.ProgrammingError(message="A database session is required")
380380
if not user.id:
381381
raise errors.UnauthorizedError(message="Users need to be authenticated in order to create groups.")
382+
allowed = await self.authz.has_permission(
383+
user, ResourceType.platform, _AuthzConverter.platform().object_id, Scope.CREATE_GROUPS
384+
)
385+
if not allowed:
386+
raise errors.ForbiddenError(
387+
message="Your administrator has limited who can create groups in the "
388+
"platform and you are not allowed to create groups.",
389+
)
390+
382391
creation_date = datetime.now(UTC).replace(microsecond=0)
383392
group = schemas.GroupORM(
384393
name=payload.name,

0 commit comments

Comments
 (0)