diff --git a/introspection_sdk/resources/connectors.py b/introspection_sdk/resources/connectors.py index 5777d0d..e079995 100644 --- a/introspection_sdk/resources/connectors.py +++ b/introspection_sdk/resources/connectors.py @@ -37,7 +37,6 @@ ConnectionTokenResult, Connector, ConnectorApp, - ConnectorApprovalPolicy, ConnectorAuthMode, ConnectorAuthorization, ConnectorAuthorizeBinding, @@ -45,9 +44,9 @@ ConnectorCreateRequest, ConnectorOAuthDiscoveryRequest, ConnectorOAuthDiscoveryResponse, - ConnectorPersonServerMode, ConnectorStatus, ConnectorUpdateRequest, + PolicyEntity, ) from introspection_sdk.schemas.pagination import Paginated from introspection_sdk.schemas.runner import RunnerIdentity @@ -70,11 +69,10 @@ def _create_body( signing_secret: str | None, metadata: dict[str, Any] | None, issuer: str | None, - person_server_mode: ConnectorPersonServerMode | str | None, person_server_url: str | None, - approval_policy: ConnectorApprovalPolicy | str | None, application_id: UUID | None, assertion_audience: str | None, + policy_entities: list[PolicyEntity | dict[str, Any]] | None, webhook_url: str | None, ) -> dict[str, Any]: # Loose public inputs (plain str) are coerced by validation: @@ -97,11 +95,10 @@ def _create_body( "signing_secret": signing_secret, "metadata": metadata, "issuer": issuer, - "person_server_mode": person_server_mode, "person_server_url": person_server_url, - "approval_policy": approval_policy, "application_id": application_id, "assertion_audience": assertion_audience, + "policy_entities": policy_entities, "webhook_url": webhook_url, } ).model_dump(mode="json", exclude_none=True) @@ -116,6 +113,7 @@ def _update_body( status: ConnectorStatus | str | None, metadata: dict[str, Any] | None, webhook_url: str | None, + policy_entities: list[PolicyEntity | dict[str, Any]] | None, client_secret: str | None, signing_secret: str | None, ) -> dict[str, Any]: @@ -130,6 +128,7 @@ def _update_body( "status": status, "metadata": metadata, "webhook_url": webhook_url, + "policy_entities": policy_entities, "client_secret": client_secret, "signing_secret": signing_secret, } @@ -359,11 +358,11 @@ def create( signing_secret: str | None = None, metadata: dict[str, Any] | None = None, issuer: str | None = None, - person_server_mode: ConnectorPersonServerMode | str | None = None, person_server_url: str | None = None, - approval_policy: ConnectorApprovalPolicy | str | None = None, application_id: UUID | None = None, assertion_audience: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, webhook_url: str | None = None, ) -> Connector: """Create a connector, or replace one with the same ``slug``. @@ -379,7 +378,10 @@ def create( the OAuth endpoints from ``.well-known`` discovery and, where the provider supports it, register an OAuth client on its own. A ``pipedream`` connector requires ``metadata["provider_workspace_id"]`` - — its Pipedream Connect project id (``proj_...``). + — its Pipedream Connect project id (``proj_...``). An ``aauth`` + connector requires ``person_server_url`` and ``application_id``; + ``policy_entities`` are the Cedar entities the egress policy gate + adds to its requests. """ payload = self._http.request( "POST", @@ -400,11 +402,10 @@ def create( signing_secret=signing_secret, metadata=metadata, issuer=issuer, - person_server_mode=person_server_mode, person_server_url=person_server_url, - approval_policy=approval_policy, application_id=application_id, assertion_audience=assertion_audience, + policy_entities=policy_entities, webhook_url=webhook_url, ), ) @@ -486,12 +487,15 @@ def update( status: ConnectorStatus | str | None = None, metadata: dict[str, Any] | None = None, webhook_url: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, client_secret: str | None = None, signing_secret: str | None = None, ) -> Connector: """Update a connector. Only provided fields change; an omitted ``client_secret`` / ``signing_secret`` stays as it is (write-only - rotation, never a clear).""" + rotation, never a clear). ``policy_entities`` replaces the + connector's Cedar entities.""" payload = self._http.request( "PATCH", f"/v1/connectors/{connector_id}", @@ -503,6 +507,7 @@ def update( status=status, metadata=metadata, webhook_url=webhook_url, + policy_entities=policy_entities, client_secret=client_secret, signing_secret=signing_secret, ), @@ -715,11 +720,11 @@ async def create( signing_secret: str | None = None, metadata: dict[str, Any] | None = None, issuer: str | None = None, - person_server_mode: ConnectorPersonServerMode | str | None = None, person_server_url: str | None = None, - approval_policy: ConnectorApprovalPolicy | str | None = None, application_id: UUID | None = None, assertion_audience: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, webhook_url: str | None = None, ) -> Connector: """Create a connector, or replace one with the same ``slug``. @@ -735,7 +740,10 @@ async def create( the OAuth endpoints from ``.well-known`` discovery and, where the provider supports it, register an OAuth client on its own. A ``pipedream`` connector requires ``metadata["provider_workspace_id"]`` - — its Pipedream Connect project id (``proj_...``). + — its Pipedream Connect project id (``proj_...``). An ``aauth`` + connector requires ``person_server_url`` and ``application_id``; + ``policy_entities`` are the Cedar entities the egress policy gate + adds to its requests. """ payload = await self._http.request( "POST", @@ -756,11 +764,10 @@ async def create( signing_secret=signing_secret, metadata=metadata, issuer=issuer, - person_server_mode=person_server_mode, person_server_url=person_server_url, - approval_policy=approval_policy, application_id=application_id, assertion_audience=assertion_audience, + policy_entities=policy_entities, webhook_url=webhook_url, ), ) @@ -844,12 +851,15 @@ async def update( status: ConnectorStatus | str | None = None, metadata: dict[str, Any] | None = None, webhook_url: str | None = None, + policy_entities: builtins.list[PolicyEntity | dict[str, Any]] + | None = None, client_secret: str | None = None, signing_secret: str | None = None, ) -> Connector: """Update a connector. Only provided fields change; an omitted ``client_secret`` / ``signing_secret`` stays as it is (write-only - rotation, never a clear).""" + rotation, never a clear). ``policy_entities`` replaces the + connector's Cedar entities.""" payload = await self._http.request( "PATCH", f"/v1/connectors/{connector_id}", @@ -861,6 +871,7 @@ async def update( status=status, metadata=metadata, webhook_url=webhook_url, + policy_entities=policy_entities, client_secret=client_secret, signing_secret=signing_secret, ), diff --git a/introspection_sdk/resources/members.py b/introspection_sdk/resources/members.py index 827e0f5..47b0c03 100644 --- a/introspection_sdk/resources/members.py +++ b/introspection_sdk/resources/members.py @@ -3,12 +3,14 @@ Members are the organization's principals: ``business`` humans, ``agent`` members, and ``customer`` members minted from an asserted identity (:class:`~introspection_sdk.schemas.runner.RunnerIdentity`). -``create`` invites a human by email; ``customer`` members are never -created here, only by identity assertion. +``create`` invites a human by email, or (owner only) records a +``customer`` member by its ``external_user_id``; customer members are +also minted by identity assertion. Reads need ``members:read``. ``update`` needs ``members:manage`` (owner), -as does seeding ``tags`` on ``create``: a member tag is access-bearing. -``metadata`` grants nothing. +as does seeding ``tags`` or ``policy_attributes`` on ``create``: a member +tag is access-bearing, and policy attributes decide what the member's agents +may do. ``metadata`` grants nothing. """ from __future__ import annotations @@ -17,6 +19,8 @@ from typing import Any from uuid import UUID +from pydantic import BaseModel + from introspection_sdk._http import _AsyncHttpClient, _HttpClient from introspection_sdk.pagination import ( AsyncPager, @@ -25,8 +29,10 @@ cursor_paginate, ) from introspection_sdk.schemas.members import ( + MEMBER_CREATE_ADAPTER, + BusinessMemberCreate, + CustomerMemberCreate, Member, - MemberCreateRequest, MemberType, MemberUpdateRequest, ) @@ -63,16 +69,13 @@ def _list_params( def _create_body( - *, - email: str, - name: str, - role: str | None, - tags: builtins.list[str] | None, - metadata: dict[str, str] | None, + member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], ) -> dict[str, Any]: - return MemberCreateRequest( - email=email, name=name, role=role, tags=tags, metadata=metadata - ).model_dump(mode="json", exclude_none=True) + # `member_type` has a default, so `exclude_none` always keeps it. + validated = MEMBER_CREATE_ADAPTER.validate_python( + member.model_dump() if isinstance(member, BaseModel) else member + ) + return validated.model_dump(mode="json", exclude_none=True) def _update_body( @@ -82,6 +85,7 @@ def _update_body( role: str | None, tags: builtins.list[str] | None, metadata: dict[str, str] | None, + policy_attributes: dict[str, Any] | None, ) -> dict[str, Any]: # `exclude_none` drops an omitted field but keeps an explicit [] / {}, # which is what clears tags / metadata — both replace wholesale. @@ -91,6 +95,7 @@ def _update_body( role=role, tags=tags, metadata=metadata, + policy_attributes=policy_attributes, ).model_dump(mode="json", exclude_none=True) @@ -150,30 +155,22 @@ def get( def create( self, - *, - email: str, - name: str, - role: str | None = None, - tags: builtins.list[str] | None = None, - metadata: dict[str, str] | None = None, + member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], ) -> Member: - """Invite a human member by email (admin or owner only). - - ``name`` must be a full name (first and last). ``tags`` are - access-bearing, so passing any needs ``members:manage``. - ``metadata`` keys are letters, digits, ``_`` and ``-``, values are - non-empty strings, at most 64 entries per write; the server answers 422 - otherwise.""" + """Create a member: a :class:`BusinessMemberCreate` invites a person + by email (admin or owner only); a :class:`CustomerMemberCreate` + records one of the organization's end users by ``external_user_id`` + (owner only, no ``role``). + + A plain dict is validated through the same union, and one without + ``member_type`` is a business member. ``member_type`` is always + sent. A business ``name`` must be a full name (first and last). + ``tags`` and ``policy_attributes`` decide access, so passing either + needs ``members:manage``. ``metadata`` keys are letters, digits, + ``_`` and ``-``, values are non-empty strings, at most 64 entries + per write; the server answers 422 otherwise.""" payload = self._http.request( - "POST", - "/v1/members", - json=_create_body( - email=email, - name=name, - role=role, - tags=tags, - metadata=metadata, - ), + "POST", "/v1/members", json=_create_body(member) ) return Member.model_validate(payload) @@ -186,12 +183,15 @@ def update( role: str | None = None, tags: builtins.list[str] | None = None, metadata: dict[str, str] | None = None, + policy_attributes: dict[str, Any] | None = None, ) -> Member: """Update a member (requires ``members:manage``). ``tags`` and ``metadata`` each replace wholesale: ``None`` leaves them untouched, ``[]`` / ``{}`` clears them. To change one metadata - key, read the member, edit its map, and send the whole map back.""" + key, read the member, edit its map, and send the whole map back. + ``policy_attributes`` replaces the member's Cedar attributes for the + egress policy gate; ``None`` leaves them untouched.""" payload = self._http.request( "PATCH", f"/v1/members/{member_id}", @@ -201,6 +201,7 @@ def update( role=role, tags=tags, metadata=metadata, + policy_attributes=policy_attributes, ), ) return Member.model_validate(payload) @@ -265,24 +266,11 @@ async def get( async def create( self, - *, - email: str, - name: str, - role: str | None = None, - tags: builtins.list[str] | None = None, - metadata: dict[str, str] | None = None, + member: BusinessMemberCreate | CustomerMemberCreate | dict[str, Any], ) -> Member: """Async twin of :meth:`Members.create`.""" payload = await self._http.request( - "POST", - "/v1/members", - json=_create_body( - email=email, - name=name, - role=role, - tags=tags, - metadata=metadata, - ), + "POST", "/v1/members", json=_create_body(member) ) return Member.model_validate(payload) @@ -295,6 +283,7 @@ async def update( role: str | None = None, tags: builtins.list[str] | None = None, metadata: dict[str, str] | None = None, + policy_attributes: dict[str, Any] | None = None, ) -> Member: """Async twin of :meth:`Members.update`.""" payload = await self._http.request( @@ -306,6 +295,7 @@ async def update( role=role, tags=tags, metadata=metadata, + policy_attributes=policy_attributes, ), ) return Member.model_validate(payload) diff --git a/introspection_sdk/schemas/connectors.py b/introspection_sdk/schemas/connectors.py index 1ed0a03..56ee5f0 100644 --- a/introspection_sdk/schemas/connectors.py +++ b/introspection_sdk/schemas/connectors.py @@ -34,6 +34,7 @@ class ConnectorAuthMode(StrEnum): IDENTITY_ASSERTION = "identity_assertion" FEDERATED_EXCHANGE = "federated_exchange" PERSON_AUTHORIZED = "person_authorized" + AAUTH = "aauth" class ConnectorStatus(StrEnum): @@ -78,20 +79,22 @@ class ConnectionBrokerSubjectType(StrEnum): PERSON = "person" -class ConnectorPersonServerMode(StrEnum): - """How a person-authorized connector reaches its Person Server.""" +class EntityUid(_ApiModel): + """A Cedar entity reference, ``{"type": "Company", "id": "acme"}``.""" - MANAGED = "managed" - BYO = "byo" - DISCOVERED = "discovered" + type: str + id: str -class ConnectorApprovalPolicy(StrEnum): - """Who approves a person-authorized connector's actions.""" +class PolicyEntity(_ApiModel): + """A Cedar entity a recipe's policies read, in Cedar's JSON entity + format: e.g. a company and the seniority its business-class bookings + need. The egress policy gate adds a connector's entities to every + request it evaluates for that connector.""" - HUMAN = "human" - JUDGE_ADVISES_HUMAN = "judge_advises_human" - JUDGE_AUTO_WITHIN_ENVELOPE = "judge_auto_within_envelope" + uid: EntityUid + attrs: dict[str, Any] = Field(default_factory=dict) + parents: list[EntityUid] = Field(default_factory=list) class Connector(_ApiModel): @@ -121,11 +124,10 @@ class Connector(_ApiModel): scopes: list[str] = [] api_hosts: list[str] = [] client_id: str | None = None - person_server_mode: ConnectorPersonServerMode | None = None person_server_url: str | None = None - approval_policy: ConnectorApprovalPolicy = ConnectorApprovalPolicy.HUMAN application_id: UUID | None = None assertion_audience: str | None = None + policy_entities: list[PolicyEntity] | None = None webhook_url: str | None = None status: ConnectorStatus = ConnectorStatus.PENDING created_by_member_id: UUID | None = None @@ -198,11 +200,14 @@ class ConnectorCreateRequest(_ApiModel): (``proj_...``); ``provider_environment`` is derived from ``environment`` by the server and need not be sent.""" issuer: str | None = None - person_server_mode: ConnectorPersonServerMode | None = None person_server_url: str | None = None - approval_policy: ConnectorApprovalPolicy | None = None + """Person Server an ``aauth`` connector's grants go through; required + for ``aauth``, together with ``application_id``.""" application_id: UUID | None = None assertion_audience: str | None = None + policy_entities: list[PolicyEntity] | None = None + """Cedar entities the egress policy gate adds to this connector's + requests.""" webhook_url: str | None = None @@ -221,6 +226,9 @@ class ConnectorUpdateRequest(_ApiModel): status: ConnectorStatus | None = None metadata: dict[str, Any] | None = None webhook_url: str | None = None + policy_entities: list[PolicyEntity] | None = None + """Replaces the connector's Cedar entities; ``None`` leaves them + untouched.""" client_secret: str | None = None signing_secret: str | None = None @@ -411,7 +419,6 @@ class ConnectionAuthorizationPending(_ApiModel): "ConnectionTokenRequest", "ConnectionTokenResult", "Connector", - "ConnectorApprovalPolicy", "ConnectorApp", "ConnectorAuthMode", "ConnectorAuthorization", @@ -421,7 +428,8 @@ class ConnectionAuthorizationPending(_ApiModel): "ConnectorOAuthClientRegistration", "ConnectorOAuthDiscoveryRequest", "ConnectorOAuthDiscoveryResponse", - "ConnectorPersonServerMode", "ConnectorStatus", "ConnectorUpdateRequest", + "EntityUid", + "PolicyEntity", ] diff --git a/introspection_sdk/schemas/members.py b/introspection_sdk/schemas/members.py index 3e3b48d..8767864 100644 --- a/introspection_sdk/schemas/members.py +++ b/introspection_sdk/schemas/members.py @@ -16,9 +16,10 @@ from datetime import datetime from enum import StrEnum +from typing import Annotated, Any, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Discriminator, Tag, TypeAdapter class _ApiModel(BaseModel): @@ -56,20 +57,72 @@ class Member(_ApiModel): connector_id: UUID | None = None integration_id: UUID | None = None is_external_credential_agent: bool = False + policy_attributes: dict[str, Any] | None = None + """Cedar JSON attributes this member carries as the principal of the + egress policy gate. A value may reference an entity as + ``{"__entity": {"type": ..., "id": ...}}``.""" -class MemberCreateRequest(_ApiModel): - """Body of ``POST /v1/members``: invite a human (``business``) member.""" +class BusinessMemberCreate(BaseModel): + """``POST /v1/members`` body for a person at the organization, invited + by email. Unknown fields are refused.""" + model_config = ConfigDict(extra="forbid") + + member_type: Literal["business"] = "business" email: str name: str """Full name — the server requires a first and a last name.""" role: str | None = None + """Defaults to ``"member"`` server-side.""" tags: list[str] | None = None """Seed tags. Access-bearing, so setting any needs ``members:manage``.""" metadata: dict[str, str] | None = None """Seed metadata. Keys are letters, digits, ``_`` and ``-``; values are non-empty strings; at most 64 entries per write.""" + policy_attributes: dict[str, Any] | None = None + """Seed Cedar attributes for the egress policy gate. They decide what + the member's agents may do, so setting any needs ``members:manage``.""" + + +class CustomerMemberCreate(BaseModel): + """``POST /v1/members`` body for one of the organization's own end + users, keyed by its id for them (owner-only). Takes no ``role``. + Unknown fields are refused.""" + + model_config = ConfigDict(extra="forbid") + + member_type: Literal["customer"] = "customer" + external_user_id: str + """``user:{id}`` or ``anon:{id}``, as a runner identity asserts it.""" + name: str | None = None + """Display only.""" + email: str | None = None + """Display only; a customer never signs in.""" + tags: list[str] | None = None + metadata: dict[str, str] | None = None + policy_attributes: dict[str, Any] | None = None + """Seed Cedar attributes for the egress policy gate.""" + + +def _member_create_tag(value: Any) -> str: + # A missing member_type is a business member, as the API reads it. + if isinstance(value, dict): + return value.get("member_type") or "business" + return getattr(value, "member_type", "business") + + +MemberCreate = Annotated[ + Annotated[BusinessMemberCreate, Tag("business")] + | Annotated[CustomerMemberCreate, Tag("customer")], + Discriminator(_member_create_tag), +] +"""Body of ``POST /v1/members``: a business or a customer member, +discriminated by ``member_type``.""" + +MEMBER_CREATE_ADAPTER: TypeAdapter[ + BusinessMemberCreate | CustomerMemberCreate +] = TypeAdapter(MemberCreate) class MemberUpdateRequest(_ApiModel): @@ -84,11 +137,17 @@ class MemberUpdateRequest(_ApiModel): metadata: dict[str, str] | None = None """Replaces the metadata map wholesale. ``None`` leaves it untouched; ``{}`` clears it.""" + policy_attributes: dict[str, Any] | None = None + """Replaces the Cedar attributes the egress policy gate sees for this + member. ``None`` leaves them untouched. Owner-only.""" __all__ = [ + "MEMBER_CREATE_ADAPTER", + "BusinessMemberCreate", + "CustomerMemberCreate", "Member", - "MemberCreateRequest", + "MemberCreate", "MemberType", "MemberUpdateRequest", ] diff --git a/tests/rest/test_connectors.py b/tests/rest/test_connectors.py index a4d0245..0d2962f 100644 --- a/tests/rest/test_connectors.py +++ b/tests/rest/test_connectors.py @@ -19,8 +19,14 @@ from introspection_sdk.schemas.connectors import ( ConnectionAuthorizationPending, ConnectionToken, + Connector, + ConnectorAuthMode, ConnectorAuthorizeBinding, + ConnectorCreateRequest, ConnectorOAuthClientRegistration, + ConnectorUpdateRequest, + EntityUid, + PolicyEntity, ) from .conftest import ( @@ -110,26 +116,135 @@ def test_create_sends_only_the_fields_given(fake_api: FakeAPI): assert "project" not in fake_api.last_request.params -def test_create_carries_person_server_configuration(fake_api: FakeAPI): - fake_api.add("POST", "/v1/connectors", json_body=connector_payload()) +APPLICATION_ID = "44444444-4444-4444-4444-444444444444" +ACME_ENTITY = { + "uid": {"type": "Company", "id": "acme"}, + "attrs": {"business_min_level": 5}, + "parents": [{"type": "Region", "id": "emea"}], +} - Connectors(fake_api.client()).create( + +def test_create_aauth_connector_sends_typed_policy_entities( + fake_api: FakeAPI, +): + fake_api.add( + "POST", + "/v1/connectors", + json_body=connector_payload( + provider="booking", + auth_mode="aauth", + person_server_url="https://person.example.com", + application_id=APPLICATION_ID, + policy_entities=[ACME_ENTITY], + ), + ) + + created = Connectors(fake_api.client()).create( name="Bookings", provider="booking", - auth_mode="person_authorized", - person_server_mode="byo", + auth_mode=ConnectorAuthMode.AAUTH, person_server_url="https://person.example.com", - approval_policy="judge_advises_human", + application_id=UUID(APPLICATION_ID), + policy_entities=[ + PolicyEntity( + uid=EntityUid(type="Company", id="acme"), + attrs={"business_min_level": 5}, + parents=[EntityUid(type="Region", id="emea")], + ) + ], ) assert fake_api.last_request.json() == { "name": "Bookings", "provider": "booking", - "auth_mode": "person_authorized", - "person_server_mode": "byo", + "auth_mode": "aauth", "person_server_url": "https://person.example.com", - "approval_policy": "judge_advises_human", + "application_id": APPLICATION_ID, + "policy_entities": [ACME_ENTITY], } + assert created.auth_mode is ConnectorAuthMode.AAUTH + assert created.policy_entities == [ + PolicyEntity.model_validate(ACME_ENTITY) + ] + + +def test_create_accepts_policy_entities_as_plain_dicts(fake_api: FakeAPI): + fake_api.add("POST", "/v1/connectors", json_body=connector_payload()) + + Connectors(fake_api.client()).create( + name="Bookings", + provider="booking", + auth_mode="aauth", + person_server_url="https://person.example.com", + application_id=UUID(APPLICATION_ID), + policy_entities=[{"uid": {"type": "Company", "id": "acme"}}], + ) + + # attrs / parents default to empty, as Cedar's JSON entity format allows. + assert fake_api.last_request.json()["policy_entities"] == [ + {"uid": {"type": "Company", "id": "acme"}, "attrs": {}, "parents": []} + ] + + +def test_create_aauth_without_person_server_surfaces_the_422( + fake_api: FakeAPI, +): + fake_api.add( + "POST", + "/v1/connectors", + status=422, + json_body={"detail": "aauth requires person_server_url"}, + ) + + with pytest.raises(ValidationError): + Connectors(fake_api.client()).create( + name="Bookings", provider="booking", auth_mode="aauth" + ) + + +def test_read_parses_policy_entities(fake_api: FakeAPI): + fake_api.add( + "GET", + CONNECTOR_PATH, + json_body=connector_payload( + auth_mode="aauth", policy_entities=[ACME_ENTITY] + ), + ) + + fetched = Connectors(fake_api.client()).get(CONNECTOR_UUID) + + assert fetched.policy_entities is not None + (entity,) = fetched.policy_entities + assert entity.uid == EntityUid(type="Company", id="acme") + assert entity.attrs == {"business_min_level": 5} + assert entity.parents == [EntityUid(type="Region", id="emea")] + + +def test_read_without_policy_entities_is_none(fake_api: FakeAPI): + fake_api.add("GET", CONNECTOR_PATH, json_body=connector_payload()) + + assert ( + Connectors(fake_api.client()).get(CONNECTOR_UUID).policy_entities + is None + ) + + +def test_update_replaces_policy_entities(fake_api: FakeAPI): + fake_api.add("PATCH", CONNECTOR_PATH, json_body=connector_payload()) + + Connectors(fake_api.client()).update( + CONNECTOR_UUID, policy_entities=[ACME_ENTITY] + ) + + assert fake_api.last_request.json() == {"policy_entities": [ACME_ENTITY]} + + +@pytest.mark.parametrize("field", ["approval_policy", "person_server_mode"]) +def test_retired_person_server_fields_are_gone(field: str): + # The control plane removed both; AAuth policy replaces them. + assert field not in Connector.model_fields + assert field not in ConnectorCreateRequest.model_fields + assert field not in ConnectorUpdateRequest.model_fields def test_list_uses_authenticated_project_and_follows_the_cursor( @@ -677,6 +792,26 @@ async def test_async_list_get_update_delete(fake_api: FakeAPI): assert await connectors.delete(CONNECTOR_UUID) is None +async def test_async_aauth_create_and_policy_update(fake_api: FakeAPI): + fake_api.add("POST", "/v1/connectors", json_body=connector_payload()) + fake_api.add("PATCH", CONNECTOR_PATH, json_body=connector_payload()) + connectors = AsyncConnectors(fake_api.async_client()) + + await connectors.create( + name="Bookings", + provider="booking", + auth_mode="aauth", + person_server_url="https://person.example.com", + application_id=UUID(APPLICATION_ID), + policy_entities=[ACME_ENTITY], + ) + assert fake_api.last_request.json()["policy_entities"] == [ACME_ENTITY] + assert fake_api.last_request.json()["auth_mode"] == "aauth" + + await connectors.update(CONNECTOR_UUID, policy_entities=[]) + assert fake_api.last_request.json() == {"policy_entities": []} + + async def test_async_connections_list_and_revoke(fake_api: FakeAPI): fake_api.add( "GET", CONNECTIONS_PATH, json_body=paginated([connection_payload()]) diff --git a/tests/rest/test_members.py b/tests/rest/test_members.py index 69ca95c..2f50c74 100644 --- a/tests/rest/test_members.py +++ b/tests/rest/test_members.py @@ -5,11 +5,16 @@ from uuid import UUID import pytest +from pydantic import ValidationError as PydanticValidationError from introspection_sdk import IntrospectionClient from introspection_sdk._errors import IntrospectionAPIError, ValidationError from introspection_sdk.resources.members import AsyncMembers, Members -from introspection_sdk.schemas.members import MemberType +from introspection_sdk.schemas.members import ( + BusinessMemberCreate, + CustomerMemberCreate, + MemberType, +) from .conftest import ( MEMBER_ID, @@ -122,14 +127,17 @@ def test_create_sends_tags_and_metadata(fake_api: FakeAPI): ), ) member = Members(fake_api.client()).create( - email="ada@example.com", - name="Ada Lovelace", - role="admin", - tags=["team:acme"], - metadata={"plan": "enterprise"}, + BusinessMemberCreate( + email="ada@example.com", + name="Ada Lovelace", + role="admin", + tags=["team:acme"], + metadata={"plan": "enterprise"}, + ) ) assert fake_api.last_request.json() == { + "member_type": "business", "email": "ada@example.com", "name": "Ada Lovelace", "role": "admin", @@ -142,10 +150,11 @@ def test_create_sends_tags_and_metadata(fake_api: FakeAPI): def test_create_omits_unset_fields(fake_api: FakeAPI): fake_api.add("POST", "/v1/members", status=201, json_body=member_payload()) Members(fake_api.client()).create( - email="ada@example.com", name="Ada Lovelace" + BusinessMemberCreate(email="ada@example.com", name="Ada Lovelace") ) assert fake_api.last_request.json() == { + "member_type": "business", "email": "ada@example.com", "name": "Ada Lovelace", } @@ -162,11 +171,110 @@ def test_create_with_tags_without_manage_scope_is_forbidden( ) with pytest.raises(IntrospectionAPIError) as exc: Members(fake_api.client()).create( - email="ada@example.com", name="Ada Lovelace", tags=["team:acme"] + BusinessMemberCreate( + email="ada@example.com", + name="Ada Lovelace", + tags=["team:acme"], + ) ) assert exc.value.status_code == 403 +def test_create_customer_member_sends_its_variant(fake_api: FakeAPI): + fake_api.add( + "POST", + "/v1/members", + status=201, + json_body=member_payload( + member_type=MemberType.CUSTOMER, + email=None, + name=None, + external_user_id="user:42", + policy_attributes={"level": 3}, + ), + ) + + member = Members(fake_api.client()).create( + CustomerMemberCreate( + external_user_id="user:42", + metadata={"plan": "pro"}, + policy_attributes={"level": 3}, + ) + ) + + assert fake_api.last_request.json() == { + "member_type": "customer", + "external_user_id": "user:42", + "metadata": {"plan": "pro"}, + "policy_attributes": {"level": 3}, + } + assert member.member_type is MemberType.CUSTOMER + assert member.external_user_id == "user:42" + + +def test_create_dict_without_member_type_is_a_business_member( + fake_api: FakeAPI, +): + fake_api.add("POST", "/v1/members", status=201, json_body=member_payload()) + + Members(fake_api.client()).create( + {"email": "ada@example.com", "name": "Ada Lovelace", "role": "admin"} + ) + + assert fake_api.last_request.json() == { + "member_type": "business", + "email": "ada@example.com", + "name": "Ada Lovelace", + "role": "admin", + } + + +def test_create_dict_with_customer_member_type(fake_api: FakeAPI): + fake_api.add("POST", "/v1/members", status=201, json_body=member_payload()) + + Members(fake_api.client()).create( + {"member_type": "customer", "external_user_id": "anon:7"} + ) + + assert fake_api.last_request.json() == { + "member_type": "customer", + "external_user_id": "anon:7", + } + + +@pytest.mark.parametrize( + "body", + [ + pytest.param({"member_type": "customer"}, id="customer-no-user-id"), + pytest.param({"name": "Ada Lovelace"}, id="business-no-email"), + pytest.param( + { + "member_type": "customer", + "external_user_id": "user:1", + "role": "admin", + }, + id="customer-with-role", + ), + pytest.param( + {"member_type": "agent", "name": "bot"}, id="unknown-member-type" + ), + ], +) +def test_create_rejects_an_invalid_body_before_sending( + fake_api: FakeAPI, body: dict[str, object] +): + with pytest.raises(PydanticValidationError): + Members(fake_api.client()).create(body) + assert fake_api.requests == [] + + +def test_create_models_require_their_identifying_fields(): + with pytest.raises(PydanticValidationError): + CustomerMemberCreate.model_validate({}) + with pytest.raises(PydanticValidationError): + BusinessMemberCreate.model_validate({"name": "Ada Lovelace"}) + + def test_update_replaces_metadata(fake_api: FakeAPI): fake_api.add( "PATCH", @@ -208,6 +316,110 @@ def test_update_surfaces_invalid_metadata(fake_api: FakeAPI): ) +POLICY_ATTRIBUTES = { + "level": 7, + "company": {"__entity": {"type": "Company", "id": "acme"}}, +} + + +def test_read_parses_policy_attributes(fake_api: FakeAPI): + fake_api.add( + "GET", + MEMBER_PATH, + json_body=member_payload(policy_attributes=POLICY_ATTRIBUTES), + ) + + member = Members(fake_api.client()).get(MEMBER_UUID) + + assert member.policy_attributes == POLICY_ATTRIBUTES + + +def test_read_without_policy_attributes_is_none(fake_api: FakeAPI): + fake_api.add("GET", MEMBER_PATH, json_body=member_payload()) + + assert ( + Members(fake_api.client()).get(MEMBER_UUID).policy_attributes is None + ) + + +def test_create_sends_policy_attributes(fake_api: FakeAPI): + fake_api.add( + "POST", + "/v1/members", + status=201, + json_body=member_payload(policy_attributes=POLICY_ATTRIBUTES), + ) + + member = Members(fake_api.client()).create( + BusinessMemberCreate( + email="ada@example.com", + name="Ada Lovelace", + policy_attributes=POLICY_ATTRIBUTES, + ) + ) + + assert fake_api.last_request.json() == { + "member_type": "business", + "email": "ada@example.com", + "name": "Ada Lovelace", + "policy_attributes": POLICY_ATTRIBUTES, + } + assert member.policy_attributes == POLICY_ATTRIBUTES + + +def test_update_replaces_policy_attributes(fake_api: FakeAPI): + fake_api.add( + "PATCH", + MEMBER_PATH, + json_body=member_payload(policy_attributes=POLICY_ATTRIBUTES), + ) + + member = Members(fake_api.client()).update( + MEMBER_UUID, policy_attributes=POLICY_ATTRIBUTES + ) + + assert fake_api.last_request.json() == { + "policy_attributes": POLICY_ATTRIBUTES + } + assert member.policy_attributes == POLICY_ATTRIBUTES + + +def test_update_policy_attributes_without_manage_scope_is_forbidden( + fake_api: FakeAPI, +): + fake_api.add( + "PATCH", + MEMBER_PATH, + status=403, + json_body={"detail": "Requires members:manage."}, + ) + with pytest.raises(IntrospectionAPIError) as exc: + Members(fake_api.client()).update( + MEMBER_UUID, policy_attributes={"level": 9} + ) + assert exc.value.status_code == 403 + + +async def test_async_create_and_update_send_policy_attributes( + fake_api: FakeAPI, +): + fake_api.add("POST", "/v1/members", status=201, json_body=member_payload()) + fake_api.add("PATCH", MEMBER_PATH, json_body=member_payload()) + members = AsyncMembers(fake_api.async_client()) + + await members.create( + CustomerMemberCreate( + external_user_id="user:42", policy_attributes={"level": 3} + ) + ) + assert fake_api.last_request.json()["policy_attributes"] == {"level": 3} + + await members.update(MEMBER_UUID, policy_attributes=POLICY_ATTRIBUTES) + assert fake_api.last_request.json() == { + "policy_attributes": POLICY_ATTRIBUTES + } + + async def test_async_list_sends_metadata_and_tag(fake_api: FakeAPI): fake_api.add( "GET", @@ -238,9 +450,11 @@ async def test_async_get_create_and_update(fake_api: FakeAPI): assert (await members.get(MEMBER_UUID)).id == MEMBER_UUID created = await members.create( - email="ada@example.com", - name="Ada Lovelace", - metadata={"plan": "pro"}, + { + "email": "ada@example.com", + "name": "Ada Lovelace", + "metadata": {"plan": "pro"}, + } ) assert created.metadata == {"plan": "pro"} assert fake_api.last_request.json()["metadata"] == {"plan": "pro"}