From 57d5a373f76bdb84becfb8e87853b3a50fb0e3c3 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sun, 2 Aug 2026 10:33:17 +0530 Subject: [PATCH] feat: rest + grpc for orgs, SSO, SCIM, webauthn; full protocol coverage 33 operations were graphql-only because the server had no RPC for them. Server 2.4.0 (authorizer #739) added the proto RPCs and REST bindings for organizations, org members, org domains, org OIDC/SAML connections, SCIM endpoints, WebAuthn ceremonies and TOTP setup, so their MethodSpecs move from GQL_ONLY to all three protocols. A further 5 -- admin_logout, admin_session, admin_meta, fga_get_model and fga_reset -- were rest+grpc-only despite each having a GraphQL op on the server; the SDK simply carried no query for them. That leaves admin_signup, update_env and generate_jwt_keys graphql-only, the only admin operations with no proto RPC. The response unwrap differs per endpoint and is the easy thing to get wrong, so each is pinned by a live test running over every protocol: a single nested payload is unwrapped (organization, org_member, org_domain, challenge, org_oidc_connection, org_saml_connection, scim_endpoint, webauthn_credentials) while flat messages are read whole -- paginated lists, and the SCIM create/rotate pair carrying endpoint plus one-time token, where unwrapping either field would drop the other. No reader was added for _env: like _update_env, its resolver is a tombstone returning "deprecated. please configure env via cli args". Requires authorizer-proto 0.2.0rc0. CI now runs against 2.4.0-rc.13. --- .github/workflows/ci.yml | 2 +- pyproject.toml | 4 +- src/authorizer/_dispatch.py | 164 ++++++++++++++++----- src/authorizer/_queries.py | 9 ++ tests/integration/test_live.py | 254 +++++++++++++++++++++++++++++++-- tests/test_admin_client.py | 123 +++++++++++++--- tests/test_admin_parity.py | 19 ++- 7 files changed, 504 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1c3214..1302a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: run: | docker run -d --name authorizer \ -p 8080:8080 -p 9091:9091 \ - quay.io/authorizer/authorizer:2.4.0-rc.9 \ + quay.io/authorizer/authorizer:2.4.0-rc.13 \ --database-type=sqlite --database-url=test.db \ --jwt-type=HS256 --jwt-secret=test \ --admin-secret=admin --client-id=ci-client --client-secret=secret \ diff --git a/pyproject.toml b/pyproject.toml index 0859079..0b1d183 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,10 @@ classifiers = [ # grpc-gateway responses with protojson (int64-as-string + single-field wrappers) # using those message types. grpcio is only needed for the optional ``grpc`` # protocol (pulled via authorizer-proto[grpc]). -dependencies = ["httpx>=0.24,<1", "protobuf>=4", "authorizer-proto>=0.1.0"] +dependencies = ["httpx>=0.24,<1", "protobuf>=4", "authorizer-proto>=0.2.0rc0"] [project.optional-dependencies] -grpc = ["authorizer-proto[grpc]>=0.1.0", "grpcio>=1.60", "protobuf>=4"] +grpc = ["authorizer-proto[grpc]>=0.2.0rc0", "grpcio>=1.60", "protobuf>=4"] dev = [ "pytest>=7", "pytest-asyncio>=0.23", diff --git a/src/authorizer/_dispatch.py b/src/authorizer/_dispatch.py index d022c81..950470e 100644 --- a/src/authorizer/_dispatch.py +++ b/src/authorizer/_dispatch.py @@ -143,24 +143,43 @@ class MethodSpec: "SmsOtpMfaSetup", "SmsOtpMfaSetupRequest", None, ), # WebAuthn/passkeys + TOTP setup: graphql-only on the server (no proto RPC). - "totp_mfa_setup": MethodSpec(GQL_ONLY, q.TOTP_MFA_SETUP, "totp_mfa_setup"), + "totp_mfa_setup": MethodSpec( + ALL, q.TOTP_MFA_SETUP, "totp_mfa_setup", "POST", "/v1/totp_mfa_setup", None, + "TotpMfaSetup", "TotpMfaSetupRequest", None, + ), "webauthn_registration_options": MethodSpec( - GQL_ONLY, q.WEBAUTHN_REGISTRATION_OPTIONS, "webauthn_registration_options", + ALL, q.WEBAUTHN_REGISTRATION_OPTIONS, "webauthn_registration_options", + "POST", "/v1/webauthn_registration_options", None, + "WebauthnRegistrationOptions", "WebauthnRegistrationOptionsRequest", None, gql_flat_vars=True, ), "webauthn_registration_verify": MethodSpec( - GQL_ONLY, q.WEBAUTHN_REGISTRATION_VERIFY, "webauthn_registration_verify" + ALL, q.WEBAUTHN_REGISTRATION_VERIFY, "webauthn_registration_verify", + "POST", "/v1/webauthn_registration_verify", None, + "WebauthnRegistrationVerify", "WebauthnRegistrationVerifyRequest", None, ), "webauthn_login_options": MethodSpec( - GQL_ONLY, q.WEBAUTHN_LOGIN_OPTIONS, "webauthn_login_options", gql_flat_vars=True + ALL, q.WEBAUTHN_LOGIN_OPTIONS, "webauthn_login_options", + "POST", "/v1/webauthn_login_options", None, + "WebauthnLoginOptions", "WebauthnLoginOptionsRequest", None, + gql_flat_vars=True, ), "webauthn_login_verify": MethodSpec( - GQL_ONLY, q.WEBAUTHN_LOGIN_VERIFY, "webauthn_login_verify" + ALL, q.WEBAUTHN_LOGIN_VERIFY, "webauthn_login_verify", + "POST", "/v1/webauthn_login_verify", None, + "WebauthnLoginVerify", "WebauthnLoginVerifyRequest", None, ), "webauthn_delete_credential": MethodSpec( - GQL_ONLY, q.WEBAUTHN_DELETE_CREDENTIAL, "webauthn_delete_credential", gql_flat_vars=True + ALL, q.WEBAUTHN_DELETE_CREDENTIAL, "webauthn_delete_credential", + "POST", "/v1/webauthn_delete_credential", None, + "WebauthnDeleteCredential", "WebauthnDeleteCredentialRequest", None, + gql_flat_vars=True, + ), + "webauthn_credentials": MethodSpec( + ALL, q.WEBAUTHN_CREDENTIALS, "webauthn_credentials", + "POST", "/v1/webauthn_credentials", "webauthn_credentials", + "WebauthnCredentials", "WebauthnCredentialsRequest", "webauthn_credentials", ), - "webauthn_credentials": MethodSpec(GQL_ONLY, q.WEBAUTHN_CREDENTIALS, "webauthn_credentials"), } @@ -175,15 +194,15 @@ class MethodSpec: "AdminLogin", "AdminLoginRequest", None, ), "admin_logout": MethodSpec( - ("rest", "grpc"), None, None, "POST", "/v1/admin/logout", None, + ALL, q.ADMIN_LOGOUT, "_admin_logout", "POST", "/v1/admin/logout", None, "AdminLogout", "AdminLogoutRequest", None, ), "admin_session": MethodSpec( - ("rest", "grpc"), None, None, "GET", "/v1/admin/session", None, + ALL, q.ADMIN_SESSION, "_admin_session", "GET", "/v1/admin/session", None, "AdminSession", "AdminSessionRequest", None, ), "admin_meta": MethodSpec( - ("rest", "grpc"), None, None, "GET", "/v1/admin/meta", "admin_meta", + ALL, q.ADMIN_META, "_admin_meta", "GET", "/v1/admin/meta", "admin_meta", "AdminMeta", "AdminMetaRequest", "admin_meta", ), "users": MethodSpec( @@ -271,7 +290,7 @@ class MethodSpec: "AuditLogs", "AuditLogsRequest", None, ), "fga_get_model": MethodSpec( - ("rest", "grpc"), None, None, "GET", "/v1/admin/fga/model", "model", + ALL, q.ADMIN_FGA_GET_MODEL, "_fga_get_model", "GET", "/v1/admin/fga/model", "model", "FgaGetModel", "FgaGetModelRequest", "model", ), "fga_write_model": MethodSpec( @@ -300,7 +319,7 @@ class MethodSpec: "FgaExpand", "FgaExpandRequest", None, ), "fga_reset": MethodSpec( - ("rest", "grpc"), None, None, "POST", "/v1/admin/fga/reset", None, + ALL, q.ADMIN_FGA_RESET, "_fga_reset", "POST", "/v1/admin/fga/reset", None, "FgaReset", "FgaResetRequest", None, ), # gql-only extras (no proto / no rest / no grpc). @@ -411,61 +430,130 @@ class MethodSpec: ), # user_organizations / org domains: graphql-only on the server (no proto RPC). "user_organizations": MethodSpec( - GQL_ONLY, q.ADMIN_USER_ORGANIZATIONS, "_user_organizations" + ALL, q.ADMIN_USER_ORGANIZATIONS, "_user_organizations", + "POST", "/v1/admin/user_organizations", None, + "UserOrganizations", "UserOrganizationsRequest", None, ), "request_org_domain": MethodSpec( - GQL_ONLY, q.ADMIN_REQUEST_ORG_DOMAIN, "_request_org_domain" + ALL, q.ADMIN_REQUEST_ORG_DOMAIN, "_request_org_domain", + "POST", "/v1/admin/request_org_domain", "challenge", + "RequestOrgDomain", "RequestOrgDomainRequest", "challenge", + ), + "verify_org_domain": MethodSpec( + ALL, q.ADMIN_VERIFY_ORG_DOMAIN, "_verify_org_domain", + "POST", "/v1/admin/verify_org_domain", "org_domain", + "VerifyOrgDomain", "VerifyOrgDomainRequest", "org_domain", ), - "verify_org_domain": MethodSpec(GQL_ONLY, q.ADMIN_VERIFY_ORG_DOMAIN, "_verify_org_domain"), "add_verified_org_domain": MethodSpec( - GQL_ONLY, q.ADMIN_ADD_VERIFIED_ORG_DOMAIN, "_add_verified_org_domain" + ALL, q.ADMIN_ADD_VERIFIED_ORG_DOMAIN, "_add_verified_org_domain", + "POST", "/v1/admin/add_verified_org_domain", "org_domain", + "AddVerifiedOrgDomain", "AddVerifiedOrgDomainRequest", "org_domain", + ), + "delete_org_domain": MethodSpec( + ALL, q.ADMIN_DELETE_ORG_DOMAIN, "_delete_org_domain", + "POST", "/v1/admin/delete_org_domain", None, + "DeleteOrgDomain", "DeleteOrgDomainRequest", None, + ), + "org_domains": MethodSpec( + ALL, q.ADMIN_ORG_DOMAINS, "_org_domains", "POST", "/v1/admin/org_domains", None, + "OrgDomains", "OrgDomainsRequest", None, ), - "delete_org_domain": MethodSpec(GQL_ONLY, q.ADMIN_DELETE_ORG_DOMAIN, "_delete_org_domain"), - "org_domains": MethodSpec(GQL_ONLY, q.ADMIN_ORG_DOMAINS, "_org_domains"), "create_organization": MethodSpec( - GQL_ONLY, q.ADMIN_CREATE_ORGANIZATION, "_create_organization" + ALL, q.ADMIN_CREATE_ORGANIZATION, "_create_organization", + "POST", "/v1/admin/create_organization", "organization", + "CreateOrganization", "CreateOrganizationRequest", "organization", ), "update_organization": MethodSpec( - GQL_ONLY, q.ADMIN_UPDATE_ORGANIZATION, "_update_organization" + ALL, q.ADMIN_UPDATE_ORGANIZATION, "_update_organization", + "POST", "/v1/admin/update_organization", "organization", + "UpdateOrganization", "UpdateOrganizationRequest", "organization", ), "delete_organization": MethodSpec( - GQL_ONLY, q.ADMIN_DELETE_ORGANIZATION, "_delete_organization" + ALL, q.ADMIN_DELETE_ORGANIZATION, "_delete_organization", + "POST", "/v1/admin/delete_organization", None, + "DeleteOrganization", "DeleteOrganizationRequest", None, + ), + "add_org_member": MethodSpec( + ALL, q.ADMIN_ADD_ORG_MEMBER, "_add_org_member", + "POST", "/v1/admin/add_org_member", "org_member", + "AddOrgMember", "AddOrgMemberRequest", "org_member", + ), + "remove_org_member": MethodSpec( + ALL, q.ADMIN_REMOVE_ORG_MEMBER, "_remove_org_member", + "POST", "/v1/admin/remove_org_member", None, + "RemoveOrgMember", "RemoveOrgMemberRequest", None, + ), + "get_organization": MethodSpec( + ALL, q.ADMIN_GET_ORGANIZATION, "_organization", + "POST", "/v1/admin/organization", "organization", + "GetOrganization", "GetOrganizationRequest", "organization", + ), + "organizations": MethodSpec( + ALL, q.ADMIN_ORGANIZATIONS, "_organizations", "POST", "/v1/admin/organizations", None, + "Organizations", "OrganizationsRequest", None, + ), + "org_members": MethodSpec( + ALL, q.ADMIN_ORG_MEMBERS, "_org_members", "POST", "/v1/admin/org_members", None, + "OrgMembers", "OrgMembersRequest", None, ), - "add_org_member": MethodSpec(GQL_ONLY, q.ADMIN_ADD_ORG_MEMBER, "_add_org_member"), - "remove_org_member": MethodSpec(GQL_ONLY, q.ADMIN_REMOVE_ORG_MEMBER, "_remove_org_member"), - "get_organization": MethodSpec(GQL_ONLY, q.ADMIN_GET_ORGANIZATION, "_organization"), - "organizations": MethodSpec(GQL_ONLY, q.ADMIN_ORGANIZATIONS, "_organizations"), - "org_members": MethodSpec(GQL_ONLY, q.ADMIN_ORG_MEMBERS, "_org_members"), "create_org_oidc_connection": MethodSpec( - GQL_ONLY, q.ADMIN_CREATE_ORG_OIDC_CONNECTION, "_create_org_oidc_connection" + ALL, q.ADMIN_CREATE_ORG_OIDC_CONNECTION, "_create_org_oidc_connection", + "POST", "/v1/admin/create_org_oidc_connection", "org_oidc_connection", + "CreateOrgOidcConnection", "CreateOrgOidcConnectionRequest", "org_oidc_connection", ), "update_org_oidc_connection": MethodSpec( - GQL_ONLY, q.ADMIN_UPDATE_ORG_OIDC_CONNECTION, "_update_org_oidc_connection" + ALL, q.ADMIN_UPDATE_ORG_OIDC_CONNECTION, "_update_org_oidc_connection", + "POST", "/v1/admin/update_org_oidc_connection", "org_oidc_connection", + "UpdateOrgOidcConnection", "UpdateOrgOidcConnectionRequest", "org_oidc_connection", ), "delete_org_oidc_connection": MethodSpec( - GQL_ONLY, q.ADMIN_DELETE_ORG_OIDC_CONNECTION, "_delete_org_oidc_connection" + ALL, q.ADMIN_DELETE_ORG_OIDC_CONNECTION, "_delete_org_oidc_connection", + "POST", "/v1/admin/delete_org_oidc_connection", None, + "DeleteOrgOidcConnection", "DeleteOrgOidcConnectionRequest", None, ), "get_org_oidc_connection": MethodSpec( - GQL_ONLY, q.ADMIN_GET_ORG_OIDC_CONNECTION, "_org_oidc_connection" + ALL, q.ADMIN_GET_ORG_OIDC_CONNECTION, "_org_oidc_connection", + "POST", "/v1/admin/org_oidc_connection", "org_oidc_connection", + "GetOrgOidcConnection", "GetOrgOidcConnectionRequest", "org_oidc_connection", ), "create_org_saml_connection": MethodSpec( - GQL_ONLY, q.ADMIN_CREATE_ORG_SAML_CONNECTION, "_create_org_saml_connection" + ALL, q.ADMIN_CREATE_ORG_SAML_CONNECTION, "_create_org_saml_connection", + "POST", "/v1/admin/create_org_saml_connection", "org_saml_connection", + "CreateOrgSamlConnection", "CreateOrgSamlConnectionRequest", "org_saml_connection", ), "update_org_saml_connection": MethodSpec( - GQL_ONLY, q.ADMIN_UPDATE_ORG_SAML_CONNECTION, "_update_org_saml_connection" + ALL, q.ADMIN_UPDATE_ORG_SAML_CONNECTION, "_update_org_saml_connection", + "POST", "/v1/admin/update_org_saml_connection", "org_saml_connection", + "UpdateOrgSamlConnection", "UpdateOrgSamlConnectionRequest", "org_saml_connection", ), "delete_org_saml_connection": MethodSpec( - GQL_ONLY, q.ADMIN_DELETE_ORG_SAML_CONNECTION, "_delete_org_saml_connection" + ALL, q.ADMIN_DELETE_ORG_SAML_CONNECTION, "_delete_org_saml_connection", + "POST", "/v1/admin/delete_org_saml_connection", None, + "DeleteOrgSamlConnection", "DeleteOrgSamlConnectionRequest", None, ), "get_org_saml_connection": MethodSpec( - GQL_ONLY, q.ADMIN_GET_ORG_SAML_CONNECTION, "_org_saml_connection" + ALL, q.ADMIN_GET_ORG_SAML_CONNECTION, "_org_saml_connection", + "POST", "/v1/admin/org_saml_connection", "org_saml_connection", + "GetOrgSamlConnection", "GetOrgSamlConnectionRequest", "org_saml_connection", ), "create_scim_endpoint": MethodSpec( - GQL_ONLY, q.ADMIN_CREATE_SCIM_ENDPOINT, "_create_scim_endpoint" + ALL, q.ADMIN_CREATE_SCIM_ENDPOINT, "_create_scim_endpoint", + "POST", "/v1/admin/create_scim_endpoint", None, + "CreateScimEndpoint", "CreateScimEndpointRequest", None, + ), + "rotate_scim_token": MethodSpec( + ALL, q.ADMIN_ROTATE_SCIM_TOKEN, "_rotate_scim_token", + "POST", "/v1/admin/rotate_scim_token", None, + "RotateScimToken", "RotateScimTokenRequest", None, ), - "rotate_scim_token": MethodSpec(GQL_ONLY, q.ADMIN_ROTATE_SCIM_TOKEN, "_rotate_scim_token"), "delete_scim_endpoint": MethodSpec( - GQL_ONLY, q.ADMIN_DELETE_SCIM_ENDPOINT, "_delete_scim_endpoint" + ALL, q.ADMIN_DELETE_SCIM_ENDPOINT, "_delete_scim_endpoint", + "POST", "/v1/admin/delete_scim_endpoint", None, + "DeleteScimEndpoint", "DeleteScimEndpointRequest", None, + ), + "get_scim_endpoint": MethodSpec( + ALL, q.ADMIN_GET_SCIM_ENDPOINT, "_scim_endpoint", + "POST", "/v1/admin/scim_endpoint", "scim_endpoint", + "GetScimEndpoint", "GetScimEndpointRequest", "scim_endpoint", ), - "get_scim_endpoint": MethodSpec(GQL_ONLY, q.ADMIN_GET_SCIM_ENDPOINT, "_scim_endpoint"), } diff --git a/src/authorizer/_queries.py b/src/authorizer/_queries.py index e006b71..8d42590 100644 --- a/src/authorizer/_queries.py +++ b/src/authorizer/_queries.py @@ -266,6 +266,15 @@ f"{{ _audit_logs(params: $data) {{ {PAGINATION_FRAGMENT} " f"audit_logs {{ {AUDIT_LOG_FRAGMENT} }} }} }}" ) +ADMIN_LOGOUT = "mutation adminLogout { _admin_logout { message } }" +ADMIN_SESSION = "query adminSession { _admin_session { message } }" +ADMIN_META = ( + "query adminMeta { _admin_meta { roles default_roles protected_roles " + "is_multi_factor_auth_service_enabled } }" +) +ADMIN_FGA_GET_MODEL = "query adminFgaGetModel { _fga_get_model { id dsl } }" +ADMIN_FGA_RESET = "mutation adminFgaReset { _fga_reset { message } }" + ADMIN_UPDATE_ENV = ( "mutation adminUpdateEnv($data: UpdateEnvRequest!) " "{ _update_env(params: $data) { message } }" diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py index 3939c19..9084269 100644 --- a/tests/integration/test_live.py +++ b/tests/integration/test_live.py @@ -371,16 +371,23 @@ def test_admin_email_template_lifecycle(admin: AuthorizerAdminClient, protocol: admin.delete_email_template(t.DeleteEmailTemplateRequest(id=created_id)) -# -- admin meta / fga get-model (rest + grpc only) --------------------------- # -def test_admin_meta_rest_grpc(protocol: str) -> None: - if protocol == "graphql": - with pytest.raises(AuthorizerError) as exc: - AuthorizerAdminClient(URL, ADMIN_SECRET, protocol="graphql").admin_meta() - assert "not available over graphql" in str(exc.value) - return +# -- admin meta / session / logout / fga get-model ---------------------------- # +# These were rest+grpc-only in the SDK despite having a GraphQL op on the +# server; the SDK simply carried no query for them. All three now agree. +def test_admin_meta_all_protocols(protocol: str) -> None: c = AuthorizerAdminClient(URL, ADMIN_SECRET, protocol=protocol, grpc_endpoint=GRPC) try: - assert isinstance(c.admin_meta().roles, list) + meta = c.admin_meta() + assert isinstance(meta.roles, list) + assert meta.roles, f"admin_meta returned no roles over {protocol}" + # REST/gRPC nest the payload under admin_meta while GraphQL returns it + # directly, so a wrong unwrap shows up as an empty dataclass here. + assert isinstance(meta.default_roles, list) + + model = c.fga_get_model() + assert model is not None + + assert c.admin_logout().message finally: c.close() @@ -454,3 +461,234 @@ def test_fga_read_list_expand(admin: AuthorizerAdminClient, fga_seed: None) -> N assert "user:1" in users.users expand = admin.fga_expand(t.FgaExpandRequest(relation="reader", object="document:1")) assert expand is not None + + +# --------------------------------------------------------------------------- # +# Organizations / org SSO / SCIM / org domains / WebAuthn. +# +# These were graphql-only until server 2.4.0 (PR #739) gave them proto RPCs and +# REST bindings. Each test below runs over EVERY configured protocol, so a wrong +# REST path, a wrong gRPC request message, or a wrong response unwrap fails here +# rather than in a user's application. The unwrap is the subtle part: some +# responses nest the payload under a single field (organization, org_domain, +# challenge, scim_endpoint) while others are read whole (paginated lists, and +# the SCIM create/rotate pair that carries endpoint + one-time token). +# --------------------------------------------------------------------------- # + + +# A throwaway self-signed certificate. The server parses idp_certificate as +# real X.509 (pem.Decode + x509.ParseCertificate), so a placeholder string +# is rejected before the transport under test is ever exercised. +_SAML_CERT = """-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUM08nMREFVxRb0V5HNytgixo/YcAwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPaWRwLmV4YW1wbGUuY29tMB4XDTI2MDgwMTE1MzcwOVoX +DTM2MDcyOTE1MzcwOVowGjEYMBYGA1UEAwwPaWRwLmV4YW1wbGUuY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2x8x+nTIA0Xkv4ylrzeVOiS1uN5J +1rBtl2mJsS1jtfMw541MEgsE42PiVqkOAtp7bJ5GVodVB/y64p/ZBvSU6VUL6JFe +oZK1zDl1Q0leA/29tHwOq8XeQYx6dnzJpXzc453qR8qyeyVHIqQH8D3DLyyWb8EB +cXLHYvNb9ERK/c290//sfhbSvFVNSZdEr3fBOL8eHAIb0uEpQWf4ifBAdhXv3GOu +zIpBTMb6T5y/S9XWOXINiTkLeXLKzFJVv5DpaKTOVoDguFqdFo8hlwu4Au3s8Op8 +Xqz2dsqk/SIlML4oF9dMf0h9x03ESRpEb+BcIzql3Mm2/TNZro7I9jqoFQIDAQAB +o1MwUTAdBgNVHQ4EFgQUq/fY8PQG/MIPKWhFG6Vu+PMq7CgwHwYDVR0jBBgwFoAU +q/fY8PQG/MIPKWhFG6Vu+PMq7CgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAVQnSp7frzPyst8aycy6oNWd/Koqrj+pMimJhaFeJgBBSp0VKdQwK +I0xkYzvIEAW/GsodywnZnE+sWyE9V4LHyLEX2pCB54KEId7yeG904eGoArlbUtly +e3uapbyT4odrectwITZ4lh/GiArjb2xVDKWpjkZwdeDvJHkWfxMRh6lwEjTgSXFl +pXH0lkasYzdXwqimSegLxLFEyPEy7Mzd0sjLAdk//9fUVtP2i4y2rQ6qBT4ZGJvB +R9gPaZ42FDBPfaHiQCGbxPsGGYrMSLyqaX8yH76TqmV/jlq8bBMD9hRYd4NgJSQ+ +of3FVYBQUyAPQt+7k6zUAppYVYdknfsM1Q== +-----END CERTIFICATE-----""" + + +def test_admin_organization_lifecycle(admin: AuthorizerAdminClient, protocol: str) -> None: + name = _unique("org") + org = admin.create_organization(t.CreateOrganizationRequest(name=name)) + assert org.id, f"create_organization returned no id over {protocol}" + assert org.name == name + try: + fetched = admin.get_organization(t.OrganizationRequest(id=org.id)) + assert fetched.id == org.id + assert fetched.name == name + + renamed = admin.update_organization( + t.UpdateOrganizationRequest(id=org.id, display_name="Renamed") + ) + assert renamed.display_name == "Renamed" + + page = admin.organizations(t.ListOrganizationsRequest()) + assert isinstance(page.organizations, list) + assert any(o.id == org.id for o in page.organizations) + assert page.pagination.total >= 1 + finally: + assert admin.delete_organization(t.OrganizationRequest(id=org.id)).message + + +def test_admin_org_member_lifecycle( + admin: AuthorizerAdminClient, client: AuthorizerClient, protocol: str +) -> None: + org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-mem"))) + email = f"{_unique('member')}@example.com" + signup = client.signup( + t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) + ) + user_id = signup.user.id + try: + member = admin.add_org_member( + t.AddOrgMemberRequest(org_id=org.id, user_id=user_id, roles=["authorizer:org_admin"]) + ) + assert member.user_id == user_id + assert "authorizer:org_admin" in member.roles + + page = admin.org_members(t.ListOrgMembersRequest(org_id=org.id)) + assert any(m.user_id == user_id for m in page.org_members) + + orgs_of_user = admin.user_organizations(t.UserOrganizationsRequest(user_id=user_id)) + assert any(uo.organization.id == org.id for uo in orgs_of_user.user_organizations) + + assert admin.remove_org_member( + t.RemoveOrgMemberRequest(org_id=org.id, user_id=user_id) + ).message + finally: + admin.delete_organization(t.OrganizationRequest(id=org.id)) + admin.delete_user(t.DeleteUserRequest(email=email)) + + +def test_admin_org_domain_lifecycle(admin: AuthorizerAdminClient, protocol: str) -> None: + org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-dom"))) + domain = f"{_unique('d')}.example.com" + try: + challenge = admin.request_org_domain( + t.RequestOrgDomainRequest(org_id=org.id, domain=domain) + ) + # Unwrapped from `challenge`; a wrong unwrap yields an empty dataclass. + assert challenge.domain == domain + assert challenge.record_value, f"no DNS challenge value over {protocol}" + + # DNS can't be satisfied here, so assert the verified path via the + # super-admin trusted-assert entry point instead. + added = admin.add_verified_org_domain( + t.AddVerifiedOrgDomainRequest(org_id=org.id, domain=domain) + ) + assert added.domain == domain + assert added.org_id == org.id + + page = admin.org_domains(t.ListOrgDomainsRequest(org_id=org.id)) + assert any(d.domain == domain for d in page.org_domains) + + assert admin.delete_org_domain(t.DeleteOrgDomainRequest(domain=domain)).message + finally: + admin.delete_organization(t.OrganizationRequest(id=org.id)) + + +def test_admin_org_oidc_connection_lifecycle( + admin: AuthorizerAdminClient, protocol: str +) -> None: + org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-oidc"))) + try: + conn = admin.create_org_oidc_connection( + t.CreateOrgOIDCConnectionRequest( + org_id=org.id, + name="idp", + issuer_url="https://accounts.google.com", + client_id="cid", + client_secret="csecret", + ) + ) + assert conn.id, f"create_org_oidc_connection returned no id over {protocol}" + assert conn.issuer_url == "https://accounts.google.com" + # The upstream client_secret must never come back on any transport. + assert not hasattr(conn, "client_secret") + + fetched = admin.get_org_oidc_connection(t.OrgOIDCConnectionRequest(id=conn.id)) + assert fetched.id == conn.id + + updated = admin.update_org_oidc_connection( + t.UpdateOrgOIDCConnectionRequest(id=conn.id, name="idp-renamed") + ) + assert updated.name == "idp-renamed" + + assert admin.delete_org_oidc_connection( + t.OrgOIDCConnectionRequest(id=conn.id) + ).message + finally: + admin.delete_organization(t.OrganizationRequest(id=org.id)) + + +def test_admin_org_saml_connection_lifecycle( + admin: AuthorizerAdminClient, protocol: str +) -> None: + org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-saml"))) + try: + conn = admin.create_org_saml_connection( + t.CreateOrgSAMLConnectionRequest( + org_id=org.id, + name="saml-idp", + idp_entity_id="urn:idp:entity", + idp_sso_url="https://idp.example.com/sso", + idp_certificate=_SAML_CERT, + ) + ) + assert conn.id, f"create_org_saml_connection returned no id over {protocol}" + # Distinct values, so a projector that swapped two fields is caught. + assert conn.idp_entity_id == "urn:idp:entity" + assert conn.idp_sso_url == "https://idp.example.com/sso" + + fetched = admin.get_org_saml_connection(t.OrgSAMLConnectionRequest(id=conn.id)) + assert fetched.idp_entity_id == "urn:idp:entity" + + updated = admin.update_org_saml_connection( + t.UpdateOrgSAMLConnectionRequest(id=conn.id, name="saml-renamed") + ) + assert updated.name == "saml-renamed" + + assert admin.delete_org_saml_connection( + t.OrgSAMLConnectionRequest(id=conn.id) + ).message + finally: + admin.delete_organization(t.OrganizationRequest(id=org.id)) + + +def test_admin_scim_endpoint_lifecycle(admin: AuthorizerAdminClient, protocol: str) -> None: + org = admin.create_organization(t.CreateOrganizationRequest(name=_unique("org-scim"))) + try: + created = admin.create_scim_endpoint(t.CreateScimEndpointRequest(org_id=org.id)) + # Two top-level fields: unwrapping either would silently drop the other. + assert created.scim_endpoint.id, f"no scim endpoint id over {protocol}" + assert created.token, "the one-time SCIM bearer token was not returned" + first_token = created.token + + fetched = admin.get_scim_endpoint(t.ScimEndpointRequest(org_id=org.id)) + assert fetched.id == created.scim_endpoint.id + + rotated = admin.rotate_scim_token(t.ScimEndpointRequest(org_id=org.id)) + assert rotated.token and rotated.token != first_token + + assert admin.delete_scim_endpoint(t.ScimEndpointRequest(org_id=org.id)).message + finally: + admin.delete_organization(t.OrganizationRequest(id=org.id)) + + +def test_webauthn_options_and_credentials( + client: AuthorizerClient, admin: AuthorizerAdminClient, protocol: str +) -> None: + """WebAuthn ceremonies over every protocol. + + Only the option-issuing and listing halves are exercised: completing a + ceremony needs a real authenticator to sign the challenge. + """ + email = f"{_unique('passkey')}@example.com" + signup = client.signup( + t.SignUpRequest(email=email, password=PASSWORD, confirm_password=PASSWORD) + ) + bearer = {"Authorization": f"Bearer {signup.access_token}"} + try: + opts = client.webauthn_registration_options(headers=bearer) + assert opts.options, f"no registration options over {protocol}" + assert "challenge" in opts.options + + creds = client.webauthn_credentials(headers=bearer) + # Unwrapped from `webauthn_credentials`; a wrong unwrap gives a non-list. + assert isinstance(creds, list) + assert creds == [] # nothing registered yet + finally: + admin.delete_user(t.DeleteUserRequest(email=email)) diff --git a/tests/test_admin_client.py b/tests/test_admin_client.py index 1fced71..7964cd0 100644 --- a/tests/test_admin_client.py +++ b/tests/test_admin_client.py @@ -131,18 +131,42 @@ def test_fga_read_tuples() -> None: assert out.continuation_token == "x" -# -- graphql-unsupported methods --------------------------------------------- # -def test_admin_meta_not_available_over_graphql() -> None: +# -- methods that used to be rest/grpc-only ---------------------------------- # +# admin_logout/session/meta and fga_get_model/fga_reset all have a graphql op on +# the server; the SDK simply had no query for them. They now span all three. + + +@respx.mock +def test_admin_meta_over_graphql() -> None: + respx.post(f"{URL}/graphql").mock( + return_value=Response( + 200, + json={"data": {"_admin_meta": {"roles": ["user"], "default_roles": ["user"]}}}, + ) + ) with _admin("graphql") as c: - with pytest.raises(AuthorizerError) as ei: - c.admin_meta() - assert "not available over graphql" in str(ei.value) + out = c.admin_meta() + assert out.roles == ["user"] + + +@respx.mock +def test_fga_get_model_over_graphql() -> None: + respx.post(f"{URL}/graphql").mock( + return_value=Response(200, json={"data": {"_fga_get_model": {"id": "m1", "dsl": "model"}}}) + ) + with _admin("graphql") as c: + out = c.fga_get_model() + assert out.id == "m1" + assert out.dsl == "model" -def test_fga_reset_not_available_over_graphql() -> None: +@respx.mock +def test_fga_reset_over_graphql() -> None: + respx.post(f"{URL}/graphql").mock( + return_value=Response(200, json={"data": {"_fga_reset": {"message": "reset"}}}) + ) with _admin("graphql") as c: - with pytest.raises(AuthorizerError): - c.fga_reset() + assert c.fga_reset().message == "reset" def test_admin_signup_not_available_over_rest() -> None: @@ -335,17 +359,82 @@ def test_rotate_scim_token_returns_token_once() -> None: assert out.token == "bearer-once" -def test_org_methods_not_available_over_rest() -> None: - # Organizations/SSO/SCIM/user_organizations/org_domains have no proto RPC - # on the server (unlike clients/trusted issuers/SAML IdP, which do) -- - # graphql-only for now. +# Organizations / org SSO / SCIM / org domains gained proto RPCs and REST +# bindings in server 2.4.0 (PR #739); they used to be graphql-only. The REST +# response envelope differs per endpoint, so each shape below pins the unwrap: +# a single nested object is unwrapped, while a flat message (a paginated list, +# or one carrying two top-level fields) is parsed whole. + + +@respx.mock +def test_create_organization_rest_unwraps_nested_object() -> None: + respx.post(f"{URL}/v1/admin/create_organization").mock( + return_value=Response(200, json={"organization": {"id": "o1", "name": "acme"}}) + ) with _admin("rest") as c: - with pytest.raises(AuthorizerError) as ei: - c.create_organization(t.CreateOrganizationRequest(name="acme")) - assert "not available over rest" in str(ei.value) + out = c.create_organization(t.CreateOrganizationRequest(name="acme")) + assert out.id == "o1" + assert out.name == "acme" + + +@respx.mock +def test_organizations_rest_reads_flat_paginated_message() -> None: + respx.post(f"{URL}/v1/admin/organizations").mock( + return_value=Response( + 200, + json={"organizations": [{"id": "o1", "name": "acme"}], "pagination": {"total": 1}}, + ) + ) + with _admin("rest") as c: + out = c.organizations() + assert out.organizations[0].id == "o1" + assert out.pagination.total == 1 + + +@respx.mock +def test_request_org_domain_rest_unwraps_challenge() -> None: + respx.post(f"{URL}/v1/admin/request_org_domain").mock( + return_value=Response( + 200, + json={ + "challenge": { + "domain": "acme.com", + "record_type": "TXT", + "record_name": "_authorizer.acme.com", + "record_value": "authorizer-verify=abc", + } + }, + ) + ) + with _admin("rest") as c: + out = c.request_org_domain(t.RequestOrgDomainRequest(org_id="o1", domain="acme.com")) + assert out.domain == "acme.com" + assert out.record_value == "authorizer-verify=abc" + + +@respx.mock +def test_get_scim_endpoint_rest_unwraps_nested_object() -> None: + respx.post(f"{URL}/v1/admin/scim_endpoint").mock( + return_value=Response(200, json={"scim_endpoint": {"id": "s1", "org_id": "o1"}}) + ) with _admin("rest") as c: - with pytest.raises(AuthorizerError): - c.organizations() + out = c.get_scim_endpoint(t.ScimEndpointRequest(org_id="o1")) + assert out.id == "s1" + + +@respx.mock +def test_create_scim_endpoint_rest_keeps_token_beside_endpoint() -> None: + # Two top-level fields: unwrapping either one would drop the other, and the + # token is shown exactly once by the server. + respx.post(f"{URL}/v1/admin/create_scim_endpoint").mock( + return_value=Response( + 200, json={"scim_endpoint": {"id": "s1", "org_id": "o1"}, "token": "bearer-once"} + ) + ) + with _admin("rest") as c: + out = c.create_scim_endpoint(t.CreateScimEndpointRequest(org_id="o1")) + assert out.scim_endpoint.id == "s1" + assert out.token == "bearer-once" @pytest.mark.asyncio diff --git a/tests/test_admin_parity.py b/tests/test_admin_parity.py index 2b6c3da..bc2be16 100644 --- a/tests/test_admin_parity.py +++ b/tests/test_admin_parity.py @@ -113,15 +113,24 @@ def test_protocol_availability_matches_spec() -> None: # gql-only extras for name in ("admin_signup", "update_env", "generate_jwt_keys"): assert d.ADMIN[name].protocols == ("graphql",) - # rest+grpc only (no graphql op) + # These have a graphql op too, so they span all three. for name in ("admin_logout", "admin_session", "admin_meta", "fga_get_model", "fga_reset"): - assert d.ADMIN[name].protocols == ("rest", "grpc") + assert d.ADMIN[name].protocols == ("graphql", "rest", "grpc") # full coverage assert d.ADMIN["users"].protocols == ("graphql", "rest", "grpc") # machine-agent-identity ops (clients/trusted issuers/SAML IdP) have proto # RPCs -- full coverage now that the stubs are re-vendored. for name in ("create_client", "trusted_issuers", "create_saml_service_provider"): assert d.ADMIN[name].protocols == ("graphql", "rest", "grpc") - # orgs/SSO/SCIM/user_organizations/org_domains are graphql-only on the server. - for name in ("create_organization", "get_scim_endpoint", "user_organizations", "org_domains"): - assert d.ADMIN[name].protocols == ("graphql",) + # orgs/SSO/SCIM/user_organizations/org_domains gained proto RPCs and REST + # bindings in server 2.4.0 (PR #739) -- full coverage. + for name in ( + "create_organization", + "get_scim_endpoint", + "user_organizations", + "org_domains", + "get_org_oidc_connection", + "get_org_saml_connection", + "request_org_domain", + ): + assert d.ADMIN[name].protocols == ("graphql", "rest", "grpc")