mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
* fix(auth): allow invited org members to start workflow runs Users invited to an org could not start workflows belonging to that org because the authorization check compared actor.selected_organization_id directly against workflow.organization_id. An invited user's selected org correctly reflects the invited org, but if the Stack Auth token resolves to a different org id than expected the strict equality fails. Per api/AGENTS.md: "Whenever you read or write an organization-scoped field, you must filter or validate by organization_id." The correct policy is org membership, not selected-org identity. - Add is_user_member_of_organization() to OrganizationClient; queries the organization_users association table directly (no lazy-load risk). - Replace the identity check in authorize_workflow_run_start() with a membership lookup. Deny when actor_user.id is not in the org's member set; error_code stays workflow_not_found to avoid leaking existence. - Update test: rename rejects_actor_from_another_org to rejects_actor_not_a_member (reflects actual policy), add positive test allows_invited_member that seeds membership and asserts has_quota=True. Closes #491 * fix(auth): skip membership check for personal workflows (organization_id=None) When workflow.organization_id is None (personal or legacy workflow with no org), the membership lookup was still called, producing a SQL IS NULL comparison that matched nothing and denied the run. Guard the check so it only runs when the workflow is org-scoped. Adds a regression test confirming that an actor with a known id can start a personal workflow without triggering is_user_member_of_organization. * fix(auth): fail closed on workflow membership lookup errors --------- Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import exists
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.future import select
|
|
|
|
from api.db.base_client import BaseDBClient
|
|
from api.db.models import (
|
|
APIKeyModel,
|
|
OrganizationModel,
|
|
organization_users_association,
|
|
)
|
|
from api.utils.api_key import generate_api_key
|
|
|
|
|
|
class OrganizationClient(BaseDBClient):
|
|
async def get_organization_by_id(
|
|
self, organization_id: int
|
|
) -> Optional[OrganizationModel]:
|
|
"""Get an organization by its ID."""
|
|
async with self.async_session() as session:
|
|
result = await session.execute(
|
|
select(OrganizationModel).where(OrganizationModel.id == organization_id)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
async def get_or_create_organization_by_provider_id(
|
|
self, org_provider_id: str, user_id: int
|
|
) -> tuple[OrganizationModel, bool]:
|
|
"""Get an existing organization by provider_id or create a new one.
|
|
|
|
Returns:
|
|
A tuple of (organization, was_created) where was_created is True if the organization
|
|
was created in this call, False if it already existed.
|
|
"""
|
|
async with self.async_session() as session:
|
|
# First try to get existing organization
|
|
result = await session.execute(
|
|
select(OrganizationModel).where(
|
|
OrganizationModel.provider_id == org_provider_id
|
|
)
|
|
)
|
|
organization = result.scalars().first()
|
|
|
|
if organization is None:
|
|
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
|
|
# This is atomic and handles race conditions at the database level
|
|
|
|
stmt = insert(OrganizationModel.__table__).values(
|
|
provider_id=org_provider_id, created_at=datetime.now(timezone.utc)
|
|
)
|
|
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
|
|
stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"])
|
|
|
|
result = await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
# Check if we actually inserted (rowcount > 0) or if there was a conflict (rowcount == 0)
|
|
was_created = result.rowcount > 0
|
|
|
|
# Now fetch the organization (either the one we just created or the one that existed)
|
|
result = await session.execute(
|
|
select(OrganizationModel).where(
|
|
OrganizationModel.provider_id == org_provider_id
|
|
)
|
|
)
|
|
organization = result.scalars().first()
|
|
|
|
if organization is None:
|
|
# This should never happen, but handle it just in case
|
|
error_msg = f"Failed to create or fetch organization with provider_id {org_provider_id}"
|
|
raise ValueError(error_msg)
|
|
|
|
# Only create API key if we actually created the organization
|
|
if was_created:
|
|
# Create a default API key for the new organization
|
|
_, key_hash, key_prefix = generate_api_key()
|
|
|
|
api_key = APIKeyModel(
|
|
organization_id=organization.id,
|
|
name="Default API Key",
|
|
key_hash=key_hash,
|
|
key_prefix=key_prefix,
|
|
is_active=True,
|
|
created_by=user_id,
|
|
)
|
|
session.add(api_key)
|
|
await session.commit()
|
|
|
|
await session.refresh(organization)
|
|
return organization, was_created
|
|
return organization, False
|
|
|
|
async def is_user_member_of_organization(
|
|
self, user_id: int, organization_id: int
|
|
) -> bool:
|
|
"""Return True if the user belongs to the given organization."""
|
|
async with self.async_session() as session:
|
|
result = await session.execute(
|
|
select(
|
|
exists().where(
|
|
(organization_users_association.c.user_id == user_id)
|
|
& (organization_users_association.c.organization_id == organization_id)
|
|
)
|
|
)
|
|
)
|
|
return bool(result.scalar())
|
|
|
|
async def add_user_to_organization(
|
|
self, user_id: int, organization_id: int
|
|
) -> None:
|
|
"""Ensure that a user is linked to an organization (many-to-many).
|
|
|
|
The association is created only if it does not already exist.
|
|
Uses INSERT ... ON CONFLICT DO NOTHING to handle race conditions.
|
|
"""
|
|
async with self.async_session() as session:
|
|
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
|
|
# This handles race conditions at the database level
|
|
|
|
stmt = insert(organization_users_association).values(
|
|
user_id=user_id, organization_id=organization_id
|
|
)
|
|
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
|
|
# The primary key constraint on (user_id, organization_id) will trigger the conflict
|
|
stmt = stmt.on_conflict_do_nothing()
|
|
|
|
await session.execute(stmt)
|
|
await session.commit()
|