Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6fcf82e
Ruff formatting and checking
bfoley12 Jun 1, 2026
767bc9c
Infra changes for rewrite
bfoley12 Jun 4, 2026
9cd35cb
upgrade dependencies for deployment
invalid-email-address Jun 2, 2026
70d7950
Revert changes to deployment of other submodules
bfoley12 Jun 17, 2026
5860ae0
Added Project.category to group together logically connected projects
bfoley12 Jun 24, 2026
e64f2f2
Removed stale 'old' directory
bfoley12 Jul 15, 2026
da7af78
Added custom str types to help with str validation (originally from c…
bfoley12 Jul 15, 2026
d47cb79
Generalized _shared/repo methods over model-defined identifiers
bfoley12 Jul 15, 2026
b136c41
Created ProjectGroups to model collections of projects
bfoley12 Jul 15, 2026
cc5cc16
Added project_groups service to coordinate manipulation of projects w…
bfoley12 Jul 16, 2026
91055fa
Created ProjectGroupServiceDep
bfoley12 Jul 16, 2026
a4a4188
Added logic for manipulation of group membership of projects
bfoley12 Jul 16, 2026
e5d715d
Added routes for individual project group operations
bfoley12 Jul 16, 2026
7b6400d
Added tests
bfoley12 Jul 16, 2026
376037e
Added __contains filter operator
bfoley12 Jul 16, 2026
38cf31f
Renamed 'category' to 'tags' for clearer terminology
bfoley12 Jul 16, 2026
859f017
Formatting
bfoley12 Jul 16, 2026
4aba4fd
Added is_public field to ProjectGroup models and fixed scope
bfoley12 Jul 16, 2026
cec41ad
ProjectGroup converts from input model and ProjectGroupIn has proper …
bfoley12 Jul 16, 2026
76fb5d3
Resolved issue where POST/PUT did not return a document with server-d…
bfoley12 Jul 16, 2026
b040ad0
Improved error catching on _shared/repo insert_one
bfoley12 Jul 16, 2026
0355ea5
Improved index structure: unique key is now minimal
bfoley12 Jul 16, 2026
0a7a2ff
Added tests for project-group duplication
bfoley12 Jul 16, 2026
6258968
Made inserting ProjectGroups a service-level concern by requiring tha…
bfoley12 Jul 16, 2026
ff4ba2c
Added settings for controlling initiative permissions
bfoley12 Jul 16, 2026
66a784e
Added flexibility to roles, allowing prefixing with resource
bfoley12 Jul 16, 2026
1d1b71a
Added Initiative domain, for a canonical grouping of projects (ie ALab)
bfoley12 Jul 16, 2026
2d1c361
Added initiative as a field on models and implemented logic for manag…
bfoley12 Jul 16, 2026
aced4c1
Removed user from repo fields
bfoley12 Jul 17, 2026
ccfd1f9
Automatically set caller as owner of ProjectGroup on Insert
bfoley12 Jul 17, 2026
b4ff3b0
Require users inserting components to have at least one project assig…
bfoley12 Jul 17, 2026
4e175e7
require_writer requires a user have at least one project assigned to …
bfoley12 Jul 17, 2026
5fe29d3
Added parsing user roles for project-group:<oid> strings
bfoley12 Jul 17, 2026
bd8ac13
Improved handling of user roles and permissions along with informativ…
bfoley12 Jul 17, 2026
4db1894
Initatives and ProjectGroup testing
bfoley12 Jul 17, 2026
6c1b1d3
Added mp_category for MP defined categories. Improve ProjectIn to exc…
bfoley12 Jul 17, 2026
e3c3f16
Exclude server-managed fields from patching
bfoley12 Jul 17, 2026
20961e7
Syncing tests with new Project models and methods
bfoley12 Jul 17, 2026
975fe9d
Switched tags to be SearchStr to improve usability
bfoley12 Jul 17, 2026
22207a9
Tests for tags as SearchStr
bfoley12 Jul 17, 2026
fbe2a5e
Fixed bug in role parsing resulting in improper roles
bfoley12 Jul 17, 2026
47eec4f
Removed requirements/
bfoley12 Jul 17, 2026
e6a648b
Undid non-mpcontribs-api changes
bfoley12 Jul 17, 2026
6b24b35
Removed .claude
bfoley12 Jul 17, 2026
54229cd
Synced start.sh with dev
bfoley12 Jul 17, 2026
77cc809
Moved Slug type into types.py
bfoley12 Jul 17, 2026
4ecedc5
fix(ContributionBase): added `version` to identifier_fields
bfoley12 Aug 5, 2026
e5457e6
docs(InitiativeRepository): modified documentation
bfoley12 Aug 5, 2026
4f38f9d
refactor(MongoDbRepository): unified patching logic and removed _reso…
bfoley12 Aug 5, 2026
296dafc
refactor(MongoDbProjectRepository): deduplicated patch logic
bfoley12 Aug 5, 2026
3a14ca2
test(tests): removed unnecessary tests and corrected test that checks…
bfoley12 Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mpcontribs-api/src/mpcontribs_api/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from mpcontribs_api.domains.attachments.router import router as attachments_router
from mpcontribs_api.domains.contributions.router import router as contributions_router
from mpcontribs_api.domains.initiatives.router import router as initiatives_router
from mpcontribs_api.domains.limits.router import router as limits_router
from mpcontribs_api.domains.project_groups.router import router as project_groups_router
from mpcontribs_api.domains.projects.router import router as projects_router
from mpcontribs_api.domains.structures.router import router as structures_router
from mpcontribs_api.domains.tables.router import router as tables_router
Expand All @@ -11,7 +13,9 @@

router.include_router(attachments_router, prefix="/attachments", tags=["attachments"])
router.include_router(contributions_router, prefix="/contributions", tags=["contributions"])
router.include_router(initiatives_router, prefix="/initiatives", tags=["initiatives"])
router.include_router(limits_router, prefix="/limits", tags=["limits"])
router.include_router(project_groups_router, prefix="/project_groups", tags=["project_groups"])
router.include_router(projects_router, prefix="/projects", tags=["projects"])
router.include_router(structures_router, prefix="/structures", tags=["structures"])
router.include_router(tables_router, prefix="/tables", tags=["tables"])
4 changes: 4 additions & 0 deletions mpcontribs-api/src/mpcontribs_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from mpcontribs_api.domains.attachments.models import Attachment
from mpcontribs_api.domains.contributions.models import Contribution
from mpcontribs_api.domains.healthcheck.router import router as healthcheck_router
from mpcontribs_api.domains.initiatives.models import Initiative
from mpcontribs_api.domains.project_groups.models import ProjectGroup
from mpcontribs_api.domains.projects.models import Project
from mpcontribs_api.domains.structures.models import Structure
from mpcontribs_api.domains.tables.models import Table
Expand Down Expand Up @@ -63,6 +65,8 @@ async def _setup_mongo(app: FastAPI, settings: Settings, stack: AsyncExitStack)
database=client[settings.mongo.db_name],
document_models=[
Project,
ProjectGroup,
Initiative,
Contribution,
Attachment,
Structure,
Expand Down
59 changes: 56 additions & 3 deletions mpcontribs-api/src/mpcontribs_api/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@

ADMIN_GROUP = settings.mongo.admin_group

# prefix to user roles to disambiguate from project roles, which are bare ids
INITIATIVE_ROLE_PREFIX = "initiative:"

# prefix for project-group roles: a group's _id (an ObjectId hex string) is granted as ``project-group:<oid>``
PROJECT_GROUP_ROLE_PREFIX = "project-group:"

# A role carrying one of these prefixes is scoped to a non-project resource; a role with none of
# them is a bare project id.
_RESOURCE_ROLE_PREFIXES = (INITIATIVE_ROLE_PREFIX, PROJECT_GROUP_ROLE_PREFIX)


class User(BaseModel):
"""User definition derived from request headers.
Expand Down Expand Up @@ -64,16 +74,59 @@ def is_anonymous(self) -> bool:
def is_admin(self) -> bool:
return (not self.is_anonymous) and (ADMIN_GROUP in self.groups)

def has_role(self, role: str) -> bool:
@property
def project_roles(self) -> list[str]:
"""The project ids this user carries, from their bare (unprefixed) roles.

Resource-scoped roles (``initiative:``, ``project-group:``) and the admin sentinel are
excluded, leaving only bare project ids.
"""
return [role for role in self.groups if role != ADMIN_GROUP and not role.startswith(_RESOURCE_ROLE_PREFIXES)]

@property
def initiative_roles(self) -> list[str]:
"""The initiative slugs this user collaborates on, decoded from their ``initiative:<slug>`` roles."""
return [role[len(INITIATIVE_ROLE_PREFIX) :] for role in self.groups if role.startswith(INITIATIVE_ROLE_PREFIX)]

@property
def project_group_roles(self) -> list[str]:
"""The project-group ids this user may access, decoded from their ``project-group:<oid>`` roles.

Values are the raw hex strings; callers that query by ``_id`` must convert them
"""
return [
role[len(PROJECT_GROUP_ROLE_PREFIX) :] for role in self.groups if role.startswith(PROJECT_GROUP_ROLE_PREFIX)
]

def has_role(self, role: str, *, resource: str | None = None) -> bool:
"""Determine whether a user has a role assigned to them.

Specifying resource as:
- ``INITIATIVE_ROLE_PREFIX`` looks for roles scoped to initiatives
- "project" looks for bare (unprefixed) project roles
- None looks for roles by matching the entire string
"""
if resource == INITIATIVE_ROLE_PREFIX[:-1]:
return role in self.initiative_roles
if resource == "project":
return role in self.project_roles
return role in self.groups

@property
def writable_projects(self) -> frozenset[str]:
"""Projects this user may write to. Admins are unbounded (handled by can_write)"""
if self.is_anonymous:
return frozenset()
# exclude the admin sentinel so it never leaks into a $in / membership test
return frozenset(g for g in self.groups if g != ADMIN_GROUP)
# only bare project roles are writable projects; the admin sentinel and resource-scoped
# roles (initiative:/project-group:) must never leak into a $in / membership test
return frozenset(self.project_roles)

def can_manage(self, id: str, resource: str) -> bool:
"""Determines whether a user can manage a resource.

If the user is known and either an admin or has a valid role assigned, they can manage
"""
return (not self.is_anonymous) and (self.is_admin or self.has_role(role=id, resource=resource))

def can_write(self, project: str) -> bool:
"""Single source of truth for write authorization."""
Expand Down
23 changes: 23 additions & 0 deletions mpcontribs-api/src/mpcontribs_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@ def _clamp_concurrency(self):
return self


class InitiativeSettings(BaseModel):
"""Limits governing user-owned initiatives."""

max_unapproved_per_owner: int = Field(
default=3,
description="Maximum number of unapproved initiatives a single owner may have at once. Enforced on create.",
)
max_projects_per_unapproved: int = Field(
default=2,
description="Maximum number of projects that may be assigned to an unapproved initiative. Enforced when a "
"project's initiative is set via PATCH.",
)


class DomainSettings(BaseModel):
"""Settings to configure the domain logic of MPContribs"""

initiatives: InitiativeSettings = Field(default_factory=InitiativeSettings)


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
Expand All @@ -179,6 +199,9 @@ class Settings(BaseSettings):
# MPContribs_otel__*
otel: ObservabilitySettings = Field(default_factory=ObservabilitySettings)

# MPContribs_domain_*
domain: DomainSettings = Field(default_factory=DomainSettings)

# SMTP Settings
mail_default_sender: str = Field(
description="SMTP Server to send out notifications on new projects and other important moments"
Expand Down
14 changes: 13 additions & 1 deletion mpcontribs-api/src/mpcontribs_api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from types_aiobotocore_s3 import S3Client

from mpcontribs_api.authz import User
from mpcontribs_api.exceptions import AuthenticationError
from mpcontribs_api.exceptions import AuthenticationError, PermissionError


def get_db(request: Request) -> AsyncDatabase:
Expand Down Expand Up @@ -72,3 +72,15 @@ def require_user(user: UserDep) -> User:
raise AuthenticationError("authentication required")
return user


def require_writer(user: UserDep) -> User:
"""Require an authenticated caller who can write to at least one project.

Controls access to creating components if you do not have contributions to attach them to.
Helps to limit orphanned components
"""
if user.is_anonymous:
raise AuthenticationError("authentication required")
if not (user.is_admin or user.writable_projects):
raise PermissionError("write access to at least one project is required")
return user
19 changes: 19 additions & 0 deletions mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
from typing import Any

from fastapi_filter.contrib.beanie import Filter
from fastapi_filter.contrib.beanie.filter import _odm_operator_transformer
from pydantic import ValidationInfo, field_validator

# Register a custom __contains filter suffix to search where lists are a superset of a provided list
_odm_operator_transformer.setdefault("contains", lambda value: {"$all": value})


class BaseFilter(Filter):
Expand All @@ -16,6 +21,20 @@ class BaseFilter(Filter):
Domain filters should subclass this instead of fastapi-filter's ``Filter`` directly.
"""

@field_validator("*", mode="before")
@classmethod
def _split_contains(cls, value: str | None, field: ValidationInfo) -> list[str] | str | None:
"""Split a comma-separated ``__contains`` query string into a list.

``FilterDepends`` collapses list-typed filter fields to a single string query param and
relies on a before-validator to re-expand it. fastapi-filter only does this for ``__in``
and ``__nin``; mirror it here for the ``contains`` operator so ``?tags__contains=a,c``
parses into ``["a", "c"]``.
"""
if field.field_name is not None and field.field_name.endswith("__contains") and isinstance(value, str):
return value.split(",") if value else []
return value

def _get_filter_conditions(self, nesting_depth: int = 1) -> list[tuple[Mapping[str, Any], Mapping[str, Any]]]:
return [
({"_id" if key == "id" else key: value for key, value in condition.items()}, options)
Expand Down
24 changes: 21 additions & 3 deletions mpcontribs-api/src/mpcontribs_api/domains/_shared/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Annotated, Any, ClassVar, Self

from beanie import Document, PydanticObjectId
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pymongo.results import DeleteResult

from mpcontribs_api import pagination
Expand All @@ -27,6 +27,22 @@ class BaseDocumentWithInput[TId](Document):
# Required, non-null, resource-specific id. Overrides Document's optional ``PydanticObjectId`` id.
id: TId = Field(alias="_id") # pyright: ignore[reportGeneralTypeIssues, reportIncompatibleVariableOverride]

@classmethod
def identifier_fields(cls) -> frozenset[str]:
"""Field names that uniquely identify a document in this collection.

This is the natural/unique key a caller can supply without first knowing the Mongo ``_id``
(e.g. ``{"name", "owner"}`` for a project group). The repository pairs these names with
caller-supplied values to locate a single resource, and rejects any value dict whose keys
don't match this set. Defaults to the primary key; subclasses with a meaningful compound key
override it.
"""
return frozenset({"id"})

def identifiers(self) -> dict[str, Any]:
"""This document's identifier field values, keyed by :meth:`identifier_fields`."""
return {field: getattr(self, field) for field in self.identifier_fields()}

@classmethod
def from_input_model(cls, data: Any) -> Self:
"""Translate a validated input payload into a full stored document."""
Expand All @@ -43,8 +59,10 @@ class DocumentOut[TId](SparseFieldsModel):

Mirrors :class:`BaseDocumentWithInput`: subclasses bind their id type as ``TId`` so each resource
owns its id type, while the field (optional, since projections may omit it) and its alias wiring
are declared once here for the repository to read off any resource's output model.
"""
are declared once here for the repository to read off any resource's output model."""

# lets POST/PUT responses correctly bring ``_id`` into ``id``, without it ``id`` ends up as None
model_config = ConfigDict(populate_by_name=True)

id: Annotated[TId | None, Field(alias="_id", serialization_alias="id")] = None

Expand Down
Loading