[feat] Extend organizations (multi-orgs, verified domains, sso providers)#3141
[feat] Extend organizations (multi-orgs, verified domains, sso providers)#3141junaway wants to merge 373 commits intochore/offline-agentafrom
organizations (multi-orgs, verified domains, sso providers)#3141Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| # SuperTokens will use our get_dynamic_oidc_provider to fetch config | ||
| supertokens_url = f"/auth/signinup?thirdPartyId={third_party_id}&redirectToPath={redirect}" | ||
|
|
||
| return RedirectResponse(url=supertokens_url, status_code=302) |
Check warning
Code scanning / CodeQL
URL redirection from remote source
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 months ago
To fix this problem, we should ensure that the redirect parameter is validated before being used in the redirection logic. There are two main approaches:
- Whitelist: If the set of allowed redirect targets is known and finite, check that
redirectmatches one of these safe values. - Relative path validation: If arbitrary relative paths are allowed, ensure that
redirectdoes NOT specify a full URL (with scheme/host), and is a local path.
For this code, since the usage is for paths within the frontend, the second approach is practical:
- Use Python's
urllib.parse.urlparseto check thatredirectdoes not contain a hostname or scheme. - Remove any backslash characters (
\) to prevent bypass. - If validation fails, redirect to a safe default (e.g.,
/).
Specific changes:
- Add an import for
urlparsefromurllib.parse. - Validate the
redirectparameter before buildingsupertokens_url:- Strip backslashes.
- Ensure
urlparse(redirect).netlocandurlparse(redirect).schemeare empty. - If the validation fails, set
redirectto/.
All code changes are to occur within api/oss/src/apis/fastapi/auth/router.py in the block for /authorize/oidc.
No change to existing functionality except improved security for the redirection.
| @@ -62,6 +62,7 @@ | ||
| # Get provider to build third_party_id | ||
| from uuid import UUID | ||
| from oss.src.dbs.postgres.organizations.dao import OrganizationProvidersDAO | ||
| from urllib.parse import urlparse | ||
|
|
||
| providers_dao = OrganizationProvidersDAO() | ||
| provider = await providers_dao.get_by_id(UUID(provider_id)) | ||
| @@ -75,7 +76,12 @@ | ||
|
|
||
| # Redirect to SuperTokens third-party signin | ||
| # SuperTokens will use our get_dynamic_oidc_provider to fetch config | ||
| supertokens_url = f"/auth/signinup?thirdPartyId={third_party_id}&redirectToPath={redirect}" | ||
| # Validate redirect to avoid open redirect vulnerability | ||
| safe_redirect = redirect.replace("\\", "") | ||
| parsed = urlparse(safe_redirect) | ||
| if parsed.netloc or parsed.scheme: | ||
| safe_redirect = "/" | ||
| supertokens_url = f"/auth/signinup?thirdPartyId={third_party_id}&redirectToPath={safe_redirect}" | ||
|
|
||
| return RedirectResponse(url=supertokens_url, status_code=302) | ||
|
|
There was a problem hiding this comment.
Pull request overview
This PR adds Single Sign-On (SSO) via OpenID Connect (OIDC) support to Agenta. The implementation introduces a comprehensive authentication discovery system, dynamic OIDC provider configuration, user identity tracking, and organization-level authentication policies.
Key changes include:
- Frontend authentication flow refactored to support multiple auth methods with dynamic discovery
- Backend auth service with OIDC provider management, SuperTokens integration with custom overrides for dynamic providers
- New database tables for user identities and organization authentication policies (EE)
Reviewed changes
Copilot reviewed 32 out of 41 changed files in this pull request and generated 21 comments.
Show a summary per file
| File | Description |
|---|---|
web/oss/src/pages/auth/[[...path]].tsx |
Refactored auth page to dynamically show/hide auth methods based on discovery endpoint and environment flags |
web/oss/src/lib/helpers/dynamicEnv.ts |
Added environment variables for email/OIDC auth feature flags |
web/oss/src/components/pages/auth/SocialAuth/index.tsx |
Added optional divider prop to conditionally show separator |
web/entrypoint.sh |
Added logic to derive AUTH_EMAIL_ENABLED and AUTH_OIDC_ENABLED from environment variables |
api/oss/src/core/auth/supertokens_overrides.py |
Implemented SuperTokens overrides for dynamic OIDC providers and custom session handling with user identities |
api/oss/src/core/auth/service.py |
Added AuthService with discovery, authentication, and authorization logic |
api/oss/src/core/auth/oidc.py |
Implemented OIDC client helper utilities for authorization and token exchange |
api/oss/src/core/auth/middleware.py |
Added organization policy enforcement middleware (EE) |
api/oss/src/apis/fastapi/auth/router.py |
Added /discover and /authorize/oidc endpoints |
api/oss/src/dbs/postgres/users/* |
Added user identities DAO, DBE, DBA, mappings, and types |
api/oss/src/dbs/postgres/organizations/* |
Added organization policies, domains, providers, invitations DAOs and models |
api/oss/databases/postgres/migrations/* |
Added migration for user_identities and organization tables |
api/ee/src/dbs/postgres/organizations/* |
EE-specific organization DBE/DBA classes |
api/test-auth.http |
Comprehensive HTTP test file documenting all auth endpoints and flows |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| class OrganizationPolicyDBA(OrganizationScopeDBA, LegacyLifecycleDBA): | ||
| __abstract__ = True | ||
|
|
||
| id = Column( | ||
| UUID(as_uuid=True), | ||
| primary_key=True, | ||
| default=uuid.uuid7, | ||
| unique=True, | ||
| nullable=False, | ||
| ) | ||
| allowed_methods = Column( | ||
| ARRAY(String), | ||
| nullable=False, | ||
| server_default="{}", | ||
| ) | ||
| invitation_only = Column( | ||
| Boolean, | ||
| nullable=False, | ||
| server_default="true", | ||
| ) | ||
| domains_only = Column( | ||
| Boolean, | ||
| nullable=False, | ||
| server_default="false", | ||
| ) | ||
| disable_root = Column( | ||
| Boolean, | ||
| nullable=False, | ||
| server_default="false", | ||
| ) | ||
|
|
||
|
|
||
| class OrganizationDomainDBA(OrganizationScopeDBA, LegacyLifecycleDBA): | ||
| __abstract__ = True | ||
|
|
||
| id = Column( | ||
| UUID(as_uuid=True), | ||
| primary_key=True, | ||
| default=uuid.uuid7, | ||
| unique=True, | ||
| nullable=False, | ||
| ) | ||
| domain = Column( | ||
| String, | ||
| nullable=False, | ||
| ) | ||
| verified = Column( | ||
| Boolean, | ||
| nullable=False, | ||
| server_default="false", | ||
| ) | ||
| verification_token = Column( | ||
| String, | ||
| nullable=True, | ||
| ) | ||
|
|
||
|
|
||
| class OrganizationProviderDBA(OrganizationScopeDBA, HeaderDBA, LegacyLifecycleDBA): | ||
| __abstract__ = True | ||
|
|
||
| id = Column( | ||
| UUID(as_uuid=True), | ||
| primary_key=True, | ||
| default=uuid.uuid7, | ||
| unique=True, | ||
| nullable=False, | ||
| ) | ||
| slug = Column( | ||
| String, | ||
| nullable=False, | ||
| ) | ||
| enabled = Column( | ||
| Boolean, | ||
| nullable=False, | ||
| server_default="true", | ||
| ) | ||
| domain_id = Column( | ||
| UUID(as_uuid=True), | ||
| nullable=True, | ||
| ) | ||
| config = Column( | ||
| JSONB(none_as_null=True), | ||
| nullable=False, | ||
| ) | ||
|
|
||
|
|
||
| class OrganizationInvitationDBA(OrganizationScopeDBA, LegacyLifecycleDBA): |
There was a problem hiding this comment.
The DBA classes inherit from LegacyLifecycleDBA but the migration creates columns matching LifecycleDBA (which includes deleted_at, created_by_id, updated_by_id, and deleted_by_id). This mismatch between the migration and the model definition will cause runtime errors. These classes should inherit from LifecycleDBA instead of LegacyLifecycleDBA to match the migration schema.
api/oss/src/core/auth/service.py
Outdated
| return False | ||
|
|
||
| # Get domain and check if it matches | ||
| domain_dto = await self.domains_dao.get_by_id(provider.domain_id) |
There was a problem hiding this comment.
The code calls await self.domains_dao.get_by_id(provider.domain_id) but the OrganizationDomainsDAO class doesn't have a get_by_id method. It only has get_by_domain, list_by_organization, and mark_verified methods. This will cause an AttributeError at runtime.
| domain_dto = await self.domains_dao.get_by_id(provider.domain_id) | |
| if hasattr(self.domains_dao, "get_by_id"): | |
| domain_dto = await self.domains_dao.get_by_id(provider.domain_id) | |
| elif hasattr(self.domains_dao, "get_by_domain"): | |
| domain_dto = await self.domains_dao.get_by_domain(domain) | |
| else: | |
| return False |
| from ee.src.core.organizations.types import ( | ||
| OrganizationPolicy, | ||
| OrganizationPolicyCreate, | ||
| OrganizationPolicyUpdate, | ||
| OrganizationDomain, | ||
| OrganizationDomainCreate, | ||
| OrganizationProvider, | ||
| OrganizationProviderCreate, | ||
| OrganizationProviderUpdate, | ||
| OrganizationInvitation, | ||
| OrganizationInvitationCreate, | ||
| ) |
There was a problem hiding this comment.
The DAO imports types from ee.src.core.organizations.types but this file is in the OSS directory (oss/src/dbs/postgres/organizations/dao.py). This creates a dependency on EE code from OSS code, which breaks the architectural separation. These types should either be defined in OSS or the DAO should only be used in EE. Based on the context, these organization-related features appear to be EE-only, so this DAO file should probably be in the EE directory.
| from ee.src.core.organizations.types import ( | ||
| OrganizationPolicy, | ||
| OrganizationPolicyCreate, | ||
| OrganizationPolicyUpdate, | ||
| OrganizationDomain, | ||
| OrganizationDomainCreate, | ||
| OrganizationProvider, | ||
| OrganizationProviderCreate, | ||
| OrganizationProviderUpdate, | ||
| OrganizationInvitation, | ||
| OrganizationInvitationCreate, | ||
| ) |
There was a problem hiding this comment.
This file imports types from ee.src.core.organizations.types but is located in the OSS directory. This creates an improper dependency on EE code from OSS code, violating the architectural separation. Since organization policies, domains, providers, and invitations appear to be EE-only features, this mappings file should be located in the EE directory structure.
| # Format: oidc:{org_id}:{provider_slug} -> sso:{org_slug}:{provider_slug} | ||
| parts = third_party_id.split(":") | ||
| org_id_str, provider_slug = parts[1], parts[2] | ||
| # TODO: Fetch org_slug from organization table | ||
| org_slug = "org" # Placeholder | ||
| method = f"sso:{org_slug}:{provider_slug}" |
There was a problem hiding this comment.
The code uses a hardcoded placeholder org_slug = "org" instead of fetching the actual organization slug from the database. This means all SSO identities will be stored with the same generic org slug, making it impossible to distinguish between different organizations' SSO providers. The TODO comment indicates this is known incomplete work that needs to be addressed.
| # Format: oidc:{org_id}:{provider_slug} -> sso:{org_slug}:{provider_slug} | |
| parts = third_party_id.split(":") | |
| org_id_str, provider_slug = parts[1], parts[2] | |
| # TODO: Fetch org_slug from organization table | |
| org_slug = "org" # Placeholder | |
| method = f"sso:{org_slug}:{provider_slug}" | |
| # Format: oidc:{org_id}:{provider_slug} -> sso:{org_id}:{provider_slug} | |
| parts = third_party_id.split(":") | |
| org_id_str, provider_slug = parts[1], parts[2] | |
| method = f"sso:{org_id_str}:{provider_slug}" |
| @@ -0,0 +1,241 @@ | |||
| """SuperTokens override functions for dynamic OIDC providers and custom session handling.""" | |||
|
|
|||
| from typing import Dict, Any, List, Optional, Union | |||
There was a problem hiding this comment.
Import of 'List' is not used.
Import of 'Union' is not used.
| from typing import Dict, Any, List, Optional, Union | |
| from typing import Dict, Any, Optional |
| from supertokens_python.recipe.thirdparty.provider import ( | ||
| Provider, | ||
| ProviderInput, | ||
| ProviderConfig, | ||
| ProviderClientConfig, | ||
| ) |
There was a problem hiding this comment.
Import of 'Provider' is not used.
| from supertokens_python.recipe.session.interfaces import ( | ||
| RecipeInterface as SessionRecipeInterface, | ||
| ) | ||
| from supertokens_python.types import User, RecipeUserId |
There was a problem hiding this comment.
Import of 'User' is not used.
| from supertokens_python.types import User, RecipeUserId | |
| from supertokens_python.types import RecipeUserId |
api/oss/src/core/auth/middleware.py
Outdated
| # For now, assume they are | ||
| is_member = True | ||
|
|
||
| if not is_member: | ||
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } | ||
|
|
There was a problem hiding this comment.
This statement is unreachable.
| # For now, assume they are | |
| is_member = True | |
| if not is_member: | |
| return { | |
| "error": "NOT_A_MEMBER", | |
| "message": "You are not a member of this organization", | |
| } | |
| # For now, assume they are a member and proceed to policy checks |
api/oss/src/core/auth/service.py
Outdated
| # For now, assume they are | ||
| is_member = True | ||
|
|
||
| if not is_member: | ||
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } | ||
|
|
There was a problem hiding this comment.
This statement is unreachable.
| # For now, assume they are | |
| is_member = True | |
| if not is_member: | |
| return { | |
| "error": "NOT_A_MEMBER", | |
| "message": "You are not a member of this organization", | |
| } | |
| # For now, assume they are (no membership check implemented yet) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 57 out of 66 changed files in this pull request and generated 18 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| from uuid import UUID | ||
| from typing import Optional, List | ||
| from sqlalchemy import select | ||
|
|
||
| from oss.src.dbs.postgres.shared.engine import engine | ||
| from oss.src.dbs.postgres.organizations.dbes import ( | ||
| OrganizationPolicyDBE, | ||
| OrganizationDomainDBE, | ||
| OrganizationProviderDBE, | ||
| OrganizationInvitationDBE, | ||
| ) | ||
| from oss.src.dbs.postgres.organizations.mappings import ( | ||
| map_policy_dbe_to_dto, | ||
| map_create_policy_dto_to_dbe, | ||
| map_update_policy_dto_to_dbe, | ||
| map_domain_dbe_to_dto, | ||
| map_create_domain_dto_to_dbe, | ||
| map_provider_dbe_to_dto, | ||
| map_create_provider_dto_to_dbe, | ||
| map_update_provider_dto_to_dbe, | ||
| map_invitation_dbe_to_dto, | ||
| map_create_invitation_dto_to_dbe, | ||
| ) | ||
| from ee.src.core.organizations.types import ( | ||
| OrganizationPolicy, | ||
| OrganizationPolicyCreate, | ||
| OrganizationPolicyUpdate, | ||
| OrganizationDomain, | ||
| OrganizationDomainCreate, | ||
| OrganizationProvider, | ||
| OrganizationProviderCreate, | ||
| OrganizationProviderUpdate, | ||
| OrganizationInvitation, | ||
| OrganizationInvitationCreate, | ||
| ) | ||
|
|
||
|
|
||
| class OrganizationPoliciesDAO: | ||
| async def create(self, dto: OrganizationPolicyCreate) -> OrganizationPolicy: | ||
| policy_dbe = map_create_policy_dto_to_dbe(dto) | ||
|
|
||
| async with engine.core_session() as session: | ||
| session.add(policy_dbe) | ||
| await session.commit() | ||
| await session.refresh(policy_dbe) | ||
|
|
||
| return map_policy_dbe_to_dto(policy_dbe) | ||
|
|
||
| async def get_by_organization( | ||
| self, organization_id: UUID | ||
| ) -> Optional[OrganizationPolicy]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationPolicyDBE).filter_by( | ||
| organization_id=organization_id | ||
| ) | ||
| result = await session.execute(stmt) | ||
| policy_dbe = result.scalar() | ||
|
|
||
| if policy_dbe is None: | ||
| return None | ||
|
|
||
| return map_policy_dbe_to_dto(policy_dbe) | ||
|
|
||
| async def update( | ||
| self, | ||
| organization_id: UUID, | ||
| dto: OrganizationPolicyUpdate, | ||
| ) -> Optional[OrganizationPolicy]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationPolicyDBE).filter_by( | ||
| organization_id=organization_id | ||
| ) | ||
| result = await session.execute(stmt) | ||
| policy_dbe = result.scalar() | ||
|
|
||
| if policy_dbe is None: | ||
| return None | ||
|
|
||
| map_update_policy_dto_to_dbe(policy_dbe, dto) | ||
| await session.commit() | ||
| await session.refresh(policy_dbe) | ||
|
|
||
| return map_policy_dbe_to_dto(policy_dbe) | ||
|
|
||
|
|
||
| class OrganizationDomainsDAO: | ||
| async def create( | ||
| self, dto: OrganizationDomainCreate | ||
| ) -> OrganizationDomain: | ||
| domain_dbe = map_create_domain_dto_to_dbe(dto) | ||
|
|
||
| async with engine.core_session() as session: | ||
| session.add(domain_dbe) | ||
| await session.commit() | ||
| await session.refresh(domain_dbe) | ||
|
|
||
| return map_domain_dbe_to_dto(domain_dbe) | ||
|
|
||
| async def get_by_id(self, domain_id: UUID) -> Optional[OrganizationDomain]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationDomainDBE).filter_by(id=domain_id) | ||
| result = await session.execute(stmt) | ||
| domain_dbe = result.scalar() | ||
|
|
||
| if domain_dbe is None: | ||
| return None | ||
|
|
||
| return map_domain_dbe_to_dto(domain_dbe) | ||
|
|
||
| async def get_by_domain(self, domain: str) -> Optional[OrganizationDomain]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationDomainDBE).filter_by(domain=domain) | ||
| result = await session.execute(stmt) | ||
| domain_dbe = result.scalar() | ||
|
|
||
| if domain_dbe is None: | ||
| return None | ||
|
|
||
| return map_domain_dbe_to_dto(domain_dbe) | ||
|
|
||
| async def list_by_organization( | ||
| self, organization_id: UUID, verified_only: bool = False | ||
| ) -> List[OrganizationDomain]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationDomainDBE).filter_by( | ||
| organization_id=organization_id | ||
| ) | ||
| if verified_only: | ||
| stmt = stmt.filter_by(verified=True) | ||
|
|
||
| result = await session.execute(stmt) | ||
| domain_dbes = result.scalars().all() | ||
|
|
||
| return [map_domain_dbe_to_dto(dbe) for dbe in domain_dbes] | ||
|
|
||
| async def mark_verified(self, domain_id: UUID) -> Optional[OrganizationDomain]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationDomainDBE).filter_by(id=domain_id) | ||
| result = await session.execute(stmt) | ||
| domain_dbe = result.scalar() | ||
|
|
||
| if domain_dbe is None: | ||
| return None | ||
|
|
||
| domain_dbe.verified = True | ||
| await session.commit() | ||
| await session.refresh(domain_dbe) | ||
|
|
||
| return map_domain_dbe_to_dto(domain_dbe) | ||
|
|
||
|
|
||
| class OrganizationProvidersDAO: | ||
| async def create( | ||
| self, dto: OrganizationProviderCreate | ||
| ) -> OrganizationProvider: | ||
| provider_dbe = map_create_provider_dto_to_dbe(dto) | ||
|
|
||
| async with engine.core_session() as session: | ||
| session.add(provider_dbe) | ||
| await session.commit() | ||
| await session.refresh(provider_dbe) | ||
|
|
||
| return map_provider_dbe_to_dto(provider_dbe) | ||
|
|
||
| async def get_by_id(self, provider_id: UUID) -> Optional[OrganizationProvider]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationProviderDBE).filter_by(id=provider_id) | ||
| result = await session.execute(stmt) | ||
| provider_dbe = result.scalar() | ||
|
|
||
| if provider_dbe is None: | ||
| return None | ||
|
|
||
| return map_provider_dbe_to_dto(provider_dbe) | ||
|
|
||
| async def get_by_slug( | ||
| self, organization_id: UUID, slug: str | ||
| ) -> Optional[OrganizationProvider]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationProviderDBE).filter_by( | ||
| organization_id=organization_id, slug=slug | ||
| ) | ||
| result = await session.execute(stmt) | ||
| provider_dbe = result.scalar() | ||
|
|
||
| if provider_dbe is None: | ||
| return None | ||
|
|
||
| return map_provider_dbe_to_dto(provider_dbe) | ||
|
|
||
| async def list_by_organization( | ||
| self, organization_id: UUID, enabled_only: bool = False | ||
| ) -> List[OrganizationProvider]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationProviderDBE).filter_by( | ||
| organization_id=organization_id | ||
| ) | ||
| if enabled_only: | ||
| stmt = stmt.filter_by(enabled=True) | ||
|
|
||
| result = await session.execute(stmt) | ||
| provider_dbes = result.scalars().all() | ||
|
|
||
| return [map_provider_dbe_to_dto(dbe) for dbe in provider_dbes] | ||
|
|
||
| async def list_by_domain( | ||
| self, domain_id: UUID, enabled_only: bool = False | ||
| ) -> List[OrganizationProvider]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationProviderDBE).filter_by(domain_id=domain_id) | ||
| if enabled_only: | ||
| stmt = stmt.filter_by(enabled=True) | ||
|
|
||
| result = await session.execute(stmt) | ||
| provider_dbes = result.scalars().all() | ||
|
|
||
| return [map_provider_dbe_to_dto(dbe) for dbe in provider_dbes] | ||
|
|
||
| async def update( | ||
| self, | ||
| provider_id: UUID, | ||
| dto: OrganizationProviderUpdate, | ||
| ) -> Optional[OrganizationProvider]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationProviderDBE).filter_by(id=provider_id) | ||
| result = await session.execute(stmt) | ||
| provider_dbe = result.scalar() | ||
|
|
||
| if provider_dbe is None: | ||
| return None | ||
|
|
||
| map_update_provider_dto_to_dbe(provider_dbe, dto) | ||
| await session.commit() | ||
| await session.refresh(provider_dbe) | ||
|
|
||
| return map_provider_dbe_to_dto(provider_dbe) | ||
|
|
||
|
|
||
| class OrganizationInvitationsDAO: | ||
| async def create( | ||
| self, dto: OrganizationInvitationCreate | ||
| ) -> OrganizationInvitation: | ||
| invitation_dbe = map_create_invitation_dto_to_dbe(dto) | ||
|
|
||
| async with engine.core_session() as session: | ||
| session.add(invitation_dbe) | ||
| await session.commit() | ||
| await session.refresh(invitation_dbe) | ||
|
|
||
| return map_invitation_dbe_to_dto(invitation_dbe) | ||
|
|
||
| async def get_by_token(self, token: str) -> Optional[OrganizationInvitation]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationInvitationDBE).filter_by(token=token) | ||
| result = await session.execute(stmt) | ||
| invitation_dbe = result.scalar() | ||
|
|
||
| if invitation_dbe is None: | ||
| return None | ||
|
|
||
| return map_invitation_dbe_to_dto(invitation_dbe) | ||
|
|
||
| async def list_by_organization( | ||
| self, organization_id: UUID, status: Optional[str] = None | ||
| ) -> List[OrganizationInvitation]: | ||
| async with engine.core_session() as session: | ||
| stmt = select(OrganizationInvitationDBE).filter_by( | ||
| organization_id=organization_id | ||
| ) | ||
| if status: | ||
| stmt = stmt.filter_by(status=status) | ||
|
|
||
| result = await session.execute(stmt) | ||
| invitation_dbes = result.scalars().all() | ||
|
|
||
| return [map_invitation_dbe_to_dto(dbe) for dbe in invitation_dbes] |
There was a problem hiding this comment.
The OSS version defines the same database models and migrations that appear in the EE version. This creates significant code duplication - the entire contents of dbas.py, dbes.py, mappings.py, and dao.py are duplicated. Since these are meant to be EE-only features (organization policies, SSO providers), they should only exist in the EE codebase. The OSS versions should either be removed or contain only stub/fallback implementations.
| from alembic import context | ||
|
|
||
| from sqlalchemy import Connection, func, insert, select, update | ||
| from sqlalchemy.orm import load_only |
There was a problem hiding this comment.
The load_only optimization is imported but only partially applied. While line 65 adds optimization for the organization query in this batch processing migration, consider whether other queries in the migration could benefit from similar optimization to reduce memory usage during large data migrations.
| showDivider = true, | ||
| }: SocialAuthProps) => { |
There was a problem hiding this comment.
The showDivider prop defaults to true to maintain backward compatibility, but the calling code in the auth page explicitly passes the value on line 225. The prop could be made required instead of optional with a default, making the API more explicit and reducing ambiguity about when the divider appears.
| sa.Column( | ||
| "expires_at", | ||
| sa.TIMESTAMP(timezone=True), | ||
| nullable=True, | ||
| ), |
There was a problem hiding this comment.
The expires_at field in the migration is correctly defined as TIMESTAMP, but the DBA model defines it as UUID (lines 125-128 in dbas.py). This mismatch between the migration and the model will cause runtime errors. The DBA model should be updated to use TIMESTAMP type.
| """In-memory state store for OIDC flows. TODO: Move to Redis for production.""" | ||
|
|
||
| from typing import Dict, Optional | ||
| from datetime import datetime, timedelta | ||
| import asyncio | ||
|
|
||
|
|
||
| class StateStore: | ||
| """Simple in-memory state store with expiration.""" | ||
|
|
||
| def __init__(self): | ||
| self._store: Dict[str, Dict] = {} | ||
| self._expiry: Dict[str, datetime] = {} | ||
|
|
||
| async def set(self, key: str, value: Dict, ttl_seconds: int = 600) -> None: | ||
| """Store a value with TTL (default 10 minutes).""" | ||
| self._store[key] = value | ||
| self._expiry[key] = datetime.utcnow() + timedelta(seconds=ttl_seconds) | ||
| await self._cleanup_expired() | ||
|
|
||
| async def get(self, key: str) -> Optional[Dict]: | ||
| """Get a value, return None if expired or not found.""" | ||
| await self._cleanup_expired() | ||
|
|
||
| if key not in self._store: | ||
| return None | ||
|
|
||
| if key in self._expiry and datetime.utcnow() > self._expiry[key]: | ||
| del self._store[key] | ||
| del self._expiry[key] | ||
| return None | ||
|
|
||
| return self._store[key] | ||
|
|
||
| async def delete(self, key: str) -> None: | ||
| """Delete a value.""" | ||
| self._store.pop(key, None) | ||
| self._expiry.pop(key, None) | ||
|
|
||
| async def _cleanup_expired(self) -> None: | ||
| """Remove expired entries.""" | ||
| now = datetime.utcnow() | ||
| expired_keys = [k for k, exp in self._expiry.items() if now > exp] | ||
| for key in expired_keys: | ||
| self._store.pop(key, None) | ||
| self._expiry.pop(key, None) | ||
|
|
||
|
|
||
| # Singleton instance | ||
| state_store = StateStore() |
There was a problem hiding this comment.
The in-memory StateStore is not suitable for production use, especially in multi-instance deployments where state needs to be shared. The TODO comment acknowledges this, but using this in production would cause OIDC flows to fail when requests hit different instances. Consider implementing Redis-backed storage before deploying to production, or add validation to prevent deployment with in-memory state.
| @@ -0,0 +1,147 @@ | |||
| """SuperTokens configuration and initialization.""" | |||
|
|
|||
| from typing import Dict, List, Any, Optional | |||
There was a problem hiding this comment.
Import of 'Optional' is not used.
| @@ -0,0 +1,343 @@ | |||
| """SuperTokens override functions for dynamic OIDC providers and custom session handling.""" | |||
|
|
|||
| from typing import Dict, Any, List, Optional, Union | |||
There was a problem hiding this comment.
Import of 'List' is not used.
| from supertokens_python.recipe.session.interfaces import ( | ||
| RecipeInterface as SessionRecipeInterface, | ||
| ) | ||
| from supertokens_python.types import User, RecipeUserId |
There was a problem hiding this comment.
Import of 'User' is not used.
api/oss/src/core/auth/middleware.py
Outdated
| is_member = True | ||
|
|
||
| if not is_member: | ||
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } | ||
|
|
There was a problem hiding this comment.
This statement is unreachable.
| is_member = True | |
| if not is_member: | |
| return { | |
| "error": "NOT_A_MEMBER", | |
| "message": "You are not a member of this organization", | |
| } |
api/oss/src/core/auth/service.py
Outdated
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } |
There was a problem hiding this comment.
This statement is unreachable.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 69 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ) | ||
|
|
||
|
|
||
| class OrganizationPolicyDBA(OrganizationScopeDBA, LegacyLifecycleDBA): |
There was a problem hiding this comment.
Inconsistent base class usage: OSS uses LifecycleDBA while EE uses LegacyLifecycleDBA. This inconsistency may lead to differences in table schemas and lifecycle field behavior between OSS and EE editions for the same tables.
| call_to_action=( | ||
| "Click the link below to accept the invitation:</p><br>" | ||
| f'<a href="{invite_link}">Accept Invitation</a>' | ||
| ), |
There was a problem hiding this comment.
The variable invite_link is referenced in the f-string but is not defined in this diff section. This will cause a NameError. Check if invite_link is defined earlier in the function or if it should be constructed here.
api/oss/src/core/auth/middleware.py
Outdated
| # Get user ID and check membership | ||
| user_id = session.get_user_id() | ||
|
|
||
| # TODO: Check if user is a member of organization | ||
| # For now, assume they are | ||
| is_member = True | ||
|
|
||
| if not is_member: | ||
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } | ||
|
|
There was a problem hiding this comment.
This statement is unreachable.
| # Get user ID and check membership | |
| user_id = session.get_user_id() | |
| # TODO: Check if user is a member of organization | |
| # For now, assume they are | |
| is_member = True | |
| if not is_member: | |
| return { | |
| "error": "NOT_A_MEMBER", | |
| "message": "You are not a member of this organization", | |
| } | |
| # Get user ID (membership enforcement not yet implemented) | |
| # TODO: Enforce that user is a member of the organization before applying policy | |
| user_id = session.get_user_id() |
api/oss/src/core/auth/service.py
Outdated
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } |
There was a problem hiding this comment.
This statement is unreachable.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 62 out of 71 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
api/oss/src/core/auth/middleware.py
Outdated
| identities = payload.get("identities", []) | ||
|
|
||
| # Get user ID and check membership | ||
| user_id = session.get_user_id() |
There was a problem hiding this comment.
Variable user_id is not used.
| user_id = session.get_user_id() |
api/oss/src/core/auth/service.py
Outdated
| return { | ||
| "error": "NOT_A_MEMBER", | ||
| "message": "You are not a member of this organization", | ||
| } |
There was a problem hiding this comment.
This statement is unreachable.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 95 out of 104 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (4)
web/entrypoint.sh:1
- The order of NEXT_PUBLIC_AGENTA_WEB_URL and NEXT_PUBLIC_AGENTA_API_URL has been swapped compared to the original, but the surrounding code context suggests this was intentional. However, this creates an inconsistency with the removed lines that had AGENTA_API_URL first. Verify this order change is intentional and doesn't break existing configurations that depend on the original ordering.
api/oss/src/utils/env.py:1 - Hardcoding a default PostHog API key in production code is a security risk. This key should only be used in development/demo environments. Consider making this explicitly conditional on environment (e.g., only default in demo mode) or removing the default entirely to force explicit configuration.
api/oss/src/utils/env.py:1 - Defaulting critical security keys to 'replace-me' is dangerous. These should fail loudly if not set in production rather than falling back to insecure defaults. Consider raising an exception when these are not configured in non-development environments.
api/oss/src/services/organization_service.py:1 - The error message 'No existing invitation found for the user' is misleading when an existing_role exists but no existing_invitation. The user may have been previously invited (thus having a role) but the invitation record is missing. Consider a more accurate message like 'User has no active invitation or existing role in this organization'.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # --> GET ORGANIZATION BATCH | ||
| query = ( | ||
| select(OrganizationDB) | ||
| .options(load_only(OrganizationDB.id, OrganizationDB.owner)) |
There was a problem hiding this comment.
Adding load_only() to limit columns fetched is good, but verify that no other attributes of OrganizationDB are accessed elsewhere in this migration that aren't included in this list. If other attributes are accessed, this could cause additional queries or errors.
| .options(load_only(OrganizationDB.id, OrganizationDB.owner)) |
…e, optimize CompareRunsMenu logic, enhance EvaluatorMetricsChart properties, and refine evaluator metrics extraction logic.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 153 out of 518 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- docs/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)
hosting/docker-compose/oss/docker-compose.dev.yml:1
- Comment states '5 seconds' but the value is 15. The comment should be updated to match the actual value: '15 seconds'.
name: agenta-oss-dev
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| labels: | ||
| - "traefik.enable=false" |
There was a problem hiding this comment.
The redis services have ports removed but retain 'traefik.enable=false' labels. Since these services never expose ports externally and Traefik isn't routing to them, these labels are redundant and can be removed for clarity.
| api_key: str | None = ( | ||
| os.getenv("POSTHOG_API_KEY") | ||
| or "phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7" | ||
| ) |
There was a problem hiding this comment.
PostHog API key is hardcoded as a fallback default. This exposes the key in source code and could allow unauthorized analytics reporting to this account. Remove the hardcoded fallback and require explicit configuration.
| api_key: str | None = ( | |
| os.getenv("POSTHOG_API_KEY") | |
| or "phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7" | |
| ) | |
| api_key: str | None = os.getenv("POSTHOG_API_KEY") |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 152 out of 519 changed files in this pull request and generated 5 comments.
Files not reviewed (1)
- docs/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # | ||
| # |
There was a problem hiding this comment.
Multiple consecutive empty comment lines serve no purpose. If these are placeholders for future services, add a descriptive comment. Otherwise, remove them to keep the file clean.
| add: Optional[List[str]] = None | ||
| # Remove columns: array of column names to remove | ||
| remove: Optional[List[str]] = None | ||
| # Replace columns: array of (old column name, new column name) to replace |
There was a problem hiding this comment.
The field uses List[Tuple[str, str]] but the description says 'array of (old column name, new column name)'. Add a more explicit docstring or inline comment explaining that each tuple is (old_name, new_name) to clarify the tuple structure for API consumers.
| # Replace columns: array of (old column name, new column name) to replace | |
| # Replace columns: array of (old column name, new column name) tuples to replace. | |
| # Each tuple is ordered as (old_name, new_name). |
| @@ -0,0 +1,50 @@ | |||
| """In-memory state store for OIDC flows. TODO: Move to Redis for production.""" | |||
There was a problem hiding this comment.
The TODO indicates this in-memory state store is not production-ready. For production deployments, this creates a critical security and reliability issue as state will be lost on restart and won't work across multiple API instances. Consider using the existing Redis infrastructure (already available via env.redis) before merging to production.
| from oss.src.utils.env import env | ||
| from oss.src.models.db_models import InvitationDB, ProjectDB | ||
| from oss.src.dbs.postgres.shared.engine import engine | ||
| from sqlalchemy import select |
There was a problem hiding this comment.
Direct SQLAlchemy usage in the service layer bypasses the DAO pattern used elsewhere in the codebase. Consider moving these database queries to a dedicated DAO class for consistency.
| await session.commit() | ||
|
|
||
| await session.refresh(user) | ||
|
|
There was a problem hiding this comment.
The session is committed at line 76, but then used at line 78 for refresh. The session context manager closes after commit in this async context. Move the refresh before commit, or restructure to keep session alive.
| await session.commit() | |
| await session.refresh(user) | |
| await session.refresh(user) | |
| await session.commit() |
…ibility, and optimize rendering logic across various sections.
… CSV parsing - Prevent grouping of flat columns with dots in their names (e.g., "agents.md") - Only group columns that came from object expansion (have parentKey property) - Add proper CSV parser to handle quoted fields with embedded newlines and commas - Fix cell value access to prioritize direct key lookup before nested path parsing - Add documentation for expanded column detection logic
…e-in-testset-upload-flow [Frontend/fix] Improve preview table in file upload flow for new testset creation
[release] v0.75.1
…luation-result-overview-page [AGE-3539]: frontend unify evaluation result page
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 153 out of 520 changed files in this pull request and generated 5 comments.
Files not reviewed (1)
- docs/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except (NoResultFound, ValueError): | ||
| await update_organization( | ||
| organization_id=organization_id, values_to_update={"owner": str(user_db.id)} | ||
| organization_id=organization_id, values_to_update={"owner_id": user_db.id} |
There was a problem hiding this comment.
Changed from 'owner' to 'owner_id', but the database model expects a UUID type. Verify that user_db.id is already a UUID and not a string to avoid type mismatches.
| # Auth service for domain policy enforcement | ||
| auth_service = AuthService() | ||
|
|
||
| def _merge_session_identities(session: Optional[Any], method: Optional[str]) -> List[str]: |
There was a problem hiding this comment.
Function name _merge_session_identities uses a leading underscore suggesting it's private/internal, but it's not a method of a class. Consider removing the underscore or organizing it as a class method if appropriate.
| replace_map = {} | ||
| if operations.columns.replace: | ||
| replace_map = {old: new for old, new in operations.columns.replace} |
There was a problem hiding this comment.
The replace_map initialization at line 913 is immediately overwritten at line 915 if operations.columns.replace exists. Consider removing the initial empty dict assignment or combining the logic.
| replace_map = {} | |
| if operations.columns.replace: | |
| replace_map = {old: new for old, new in operations.columns.replace} | |
| replace_map = {old: new for old, new in (operations.columns.replace or [])} |
| Note: Only methods that are available (true) are included in the response. | ||
| Missing methods should be assumed false on the client side. | ||
| """ | ||
| print(f"[DISCOVERY] Starting discovery for email: {email}") |
There was a problem hiding this comment.
Using print() for logging throughout this file instead of the log object imported at the top. Replace print statements with proper logging calls (e.g., log.info(), log.debug()) for consistency with the rest of the codebase.
| stmt=stmt, | ||
| DBE=self.BlobDBE, | ||
| attribute="id", # UUID7 - use id for cursor-based pagination | ||
| attribute="created_at", # Blob IDs are content-hashed (UUID5), use timestamp for ordering |
There was a problem hiding this comment.
Comment explains the change but the original code used 'id' for UUID7. Verify that all blob IDs in the system are indeed UUID5 (content-hashed) and not UUID7, as mixing ordering strategies could cause inconsistent pagination.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 152 out of 520 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- docs/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| invitation = await create_invitation(existing_role, project_id, payload.email) | ||
| else: | ||
| raise HTTPException( | ||
| status_code=404, | ||
| detail="No existing invitation found for the user", | ||
| ) |
There was a problem hiding this comment.
The else clause raises a 404 when there's no existing invitation OR role, but the function is called 'resend_user_organization_invite'. If the user was never invited, this should probably return a different error indicating that there's no invitation to resend, rather than proceeding to create a new invitation with an undefined role.
| stmt=stmt, | ||
| DBE=self.BlobDBE, | ||
| attribute="id", # UUID7 - use id for cursor-based pagination | ||
| attribute="created_at", # Blob IDs are content-hashed (UUID5), use timestamp for ordering |
There was a problem hiding this comment.
The comment explains the change from 'id' to 'created_at' for ordering, but this breaks cursor-based pagination semantics if multiple blobs have the same timestamp. Consider documenting this limitation or using a composite key (created_at, id) for stable ordering.
| attribute="created_at", # Blob IDs are content-hashed (UUID5), use timestamp for ordering | |
| # Blob IDs are content-hashed (UUID5), so they are not suitable for | |
| # chronological ordering. We therefore use `created_at` as the primary | |
| # sort key for windowing. Note: using `created_at` alone means that | |
| # cursor-based pagination is not strictly stable if multiple blobs share | |
| # the same timestamp, because there is no secondary tie-breaker in the | |
| # ORDER BY clause (e.g. `(created_at, id)`). Callers should be aware of | |
| # this limitation when relying on pagination semantics. | |
| attribute="created_at", |
| tags: Optional[Dict[str, Any]] = None | ||
| meta: Optional[Dict[str, Any]] = None | ||
| # | ||
| owner_id: UUID |
There was a problem hiding this comment.
The owner_id field is required (no Optional), but in the Organization model it's not marked as required. This could cause serialization issues if owner_id is None in the database. Consider making it Optional[UUID] to match the database schema's nullable constraint pattern, or ensure it's always set during creation.
| owner_id: UUID | |
| owner_id: Optional[UUID] = None |
| # Check for error status and propagate it | ||
| if response.status and response.status.code and response.status.code >= 400: | ||
| error_message = response.status.message or "Custom code execution failed" | ||
| raise RuntimeError(error_message) |
There was a problem hiding this comment.
The generic error message 'Custom code execution failed' doesn't provide enough context for debugging. Consider including the status code in the error message, e.g., f'Custom code execution failed with status {response.status.code}: {error_message}'.
| raise RuntimeError(error_message) | |
| raise RuntimeError( | |
| f"Custom code execution failed with status {response.status.code}: {error_message}" | |
| ) |
| """ | ||
| print(f"[DISCOVERY] Starting discovery for email: {email}") | ||
|
|
||
| # Extract domain from email (if provided) | ||
| domain = email.split("@")[1] if email and "@" in email else None | ||
| print(f"[DISCOVERY] Extracted domain: {domain}") | ||
|
|
||
| # Check if user exists only when email looks valid | ||
| user = None |
There was a problem hiding this comment.
This domain extraction doesn't validate that there's exactly one '@' symbol. An email like 'user@@example.com' would extract '@example.com' as the domain. Use email.count('@') == 1 in the condition or validate email format first.
| """ | |
| print(f"[DISCOVERY] Starting discovery for email: {email}") | |
| # Extract domain from email (if provided) | |
| domain = email.split("@")[1] if email and "@" in email else None | |
| print(f"[DISCOVERY] Extracted domain: {domain}") | |
| # Check if user exists only when email looks valid | |
| user = None | |
| # Extract domain from email (if provided and structurally valid) | |
| domain = email.split("@")[1] if email and email.count("@") == 1 else None | |
| print(f"[DISCOVERY] Extracted domain: {domain}") | |
| # Check if user exists only when email looks valid | |
| user = None | |
| user_exists = False | |
| user_id = None | |
| if email and email.count("@") == 1: |
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U postgres"] | ||
| test: ["CMD-SHELL", "pg_isready -U username -d agenta_oss_core"] |
There was a problem hiding this comment.
The healthcheck uses hardcoded username 'username' instead of the ${POSTGRES_USER} variable. This will fail if POSTGRES_USER is set to a different value. Use 'pg_isready -U ${POSTGRES_USER:-username} -d agenta_oss_core' to respect the environment variable.
| test: ["CMD-SHELL", "pg_isready -U username -d agenta_oss_core"] | |
| test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-username} -d agenta_oss_core"] |
| # | ||
| # |
There was a problem hiding this comment.
There are 17 consecutive lines of empty comments (lines 453-469) at the end of the file before the networks section. These appear to be placeholder or accidentally committed. Consider removing them to clean up the file.
| @@ -22,7 +22,7 @@ agenta = ">=0.72.1" | |||
|
|
|||
| # Core framework dependencies | |||
| fastapi = ">=0.127" | |||
There was a problem hiding this comment.
The pydantic email extra is added but there's no comment explaining why. Since this PR adds email validation in multiple places (domain extraction, user identities), consider adding a comment explaining that this enables EmailStr validators.
| fastapi = ">=0.127" | |
| fastapi = ">=0.127" | |
| # Enable Pydantic's email validators (e.g. EmailStr) used for email/domain validation. |
No description provided.