Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ snapshots that make `--apply` reversible.
1. **Get user and org metadata**

```bash
src-auth-perms-sync sync-saml-orgs
src-auth-perms-sync sync-saml-orgs --full
```

- Queries the Sourcegraph instance for auth providers, users, users' SAML groups, and orgs
Expand All @@ -278,12 +278,41 @@ snapshots that make `--apply` reversible.
2. **Apply org sync**

```bash
src-auth-perms-sync sync-saml-orgs --apply
src-auth-perms-sync sync-saml-orgs --full --apply
```

- Creates the orgs if they don't exist, and sync the members from the SAML groups to the orgs
- `--sync-saml-orgs` can also be added to a `set` run, to run both at the same time

3. **Scoped org sync for selected users**

```bash
src-auth-perms-sync sync-saml-orgs --users alice,bob
src-auth-perms-sync sync-saml-orgs --created-after 2026-06-01
src-auth-perms-sync sync-saml-orgs --users-without-explicit-perms
```

- Same user filters as `get` and `set`; a mode flag is required — there
is no bare `sync-saml-orgs`

### Org sync behavior

- Org names are `synced-<configID>-<group name>` (non-alphanumeric characters
become `-`). The `synced-` prefix marks tool ownership: the sync only ever
modifies orgs whose name carries it, so manually created orgs are never touched.
- The org sync mode is always explicit — no surprises:
- **Full** (`sync-saml-orgs --full`, or `set --full` / `--repos*`
`--sync-saml-orgs`): converges every synced org against all users. A synced
org whose SAML group disappeared has all members removed, but the org itself
is kept (its settings survive in case the group comes back).
- **Scoped** (user filters on `sync-saml-orgs`, or `set --users` /
`--users-without-explicit-perms` / `--created-after` with
`--sync-saml-orgs`): syncs org membership for exactly the selected users —
per-user additions AND removals, computed from each user's own SAML
assertion and org list. Other users' memberships never change, and no full
user scan or org member listing is needed, so API traffic stays
proportional to the selection.

## Options

Run `src-auth-perms-sync --help` for options
Expand Down
82 changes: 76 additions & 6 deletions src/src_auth_perms_sync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,15 @@
)
SYNC_SAML_ORGS_CONFIG_FIELDS = src.config_field_names(
*COMMON_CONFIG_FIELDS_BEFORE,
"full",
"users",
"users_without_explicit_perms",
"created_after",
"apply",
"no_backup",
"artifacts_dir",
"no_files",
"explicit_permissions_batch_size",
"parallelism",
*COMMON_CONFIG_FIELDS_AFTER,
)
Expand Down Expand Up @@ -146,6 +151,12 @@
"repos_without_explicit_perms": "set_repos_without_explicit_perms_sync_saml_orgs",
"repos_created_after": "set_repos_created_after_sync_saml_orgs",
}
SYNC_SAML_ORGS_ARTIFACT_NAMES: dict[str, str] = {
"full": "sync-saml-orgs-full-{run_mode}",
"users": "sync-saml-orgs-users-{run_mode}",
"users_without_explicit_perms": "sync-saml-orgs-users-without-explicit-perms-{run_mode}",
"created_after": "sync-saml-orgs-created-after-{run_mode}",
}
SYNC_SET_COMMAND_ARTIFACT_NAMES: dict[permission_types.SetCommandMode, str] = {
"full": "set-sync-saml-orgs-{run_mode}",
"users": "set-add-users-sync-saml-orgs-{run_mode}",
Expand Down Expand Up @@ -431,6 +442,7 @@ def validate_config(command_name: CommandName, config: Config) -> None:
validate_user_filter_selection(command_name, config)
validate_repository_filter_selection(command_name, config)
validate_set_mode_selection(command_name, config)
validate_sync_saml_orgs_mode_selection(command_name, config)


def validate_command_options(command_name: CommandName, config: Config) -> None:
Expand Down Expand Up @@ -460,10 +472,11 @@ def validate_user_filter_selection(command_name: CommandName, config: Config) ->
config_error("choose only one of --users or --users-without-explicit-perms")

user_filter_selected = user_scope_filter_count > 0 or config.created_after is not None
user_filter_allowed = command_name in {"get", "set"}
user_filter_allowed = command_name in {"get", "set", "sync_saml_orgs"}
if user_filter_selected and not user_filter_allowed:
config_error(
"--users, --users-without-explicit-perms, and --created-after require get or set"
"--users, --users-without-explicit-perms, and --created-after "
"require get, set, or sync-saml-orgs"
)


Expand Down Expand Up @@ -495,10 +508,39 @@ def validate_repository_filter_selection(command_name: CommandName, config: Conf
config_error("choose either user filters or repo filters, not both")


def validate_sync_saml_orgs_mode_selection(command_name: CommandName, config: Config) -> None:
"""Validate sync-saml-orgs command mode flags."""
if command_name != "sync_saml_orgs":
return

if config.full and config.created_after is not None:
config_error(
"--full cannot be combined with --created-after; full mode already syncs every user"
)
if config.full and (config.users or config.users_without_explicit_perms):
config_error(
"with sync-saml-orgs, choose at most one of --full, --users, "
"or --users-without-explicit-perms"
)
mode_selected = any(
(
config.full,
bool(config.users),
config.users_without_explicit_perms,
config.created_after is not None,
)
)
if not mode_selected:
config_error(
"sync-saml-orgs requires one of --full, --users, "
"--users-without-explicit-perms, or --created-after"
)


def validate_set_mode_selection(command_name: CommandName, config: Config) -> None:
"""Validate set command mode flags."""
if config.full and command_name != "set":
config_error("--full requires the set command")
if config.full and command_name not in {"set", "sync_saml_orgs"}:
config_error("--full requires the set or sync-saml-orgs command")

if command_name != "set":
return
Expand Down Expand Up @@ -600,11 +642,24 @@ def resolve_command(command_name: CommandName, config: Config) -> ResolvedComman
return ResolvedCommand(
name="sync_saml_orgs",
log_name="sync_saml_orgs",
artifact_name=f"sync-saml-orgs-{run_mode}",
artifact_name=SYNC_SAML_ORGS_ARTIFACT_NAMES[sync_saml_orgs_mode(config)].format(
run_mode=run_mode
),
sync_saml_organizations=True,
)


def sync_saml_orgs_mode(config: Config) -> str:
"""Return the validated standalone sync-saml-orgs mode."""
if config.users:
return "users"
if config.users_without_explicit_perms:
return "users_without_explicit_perms"
if config.created_after is not None:
return "created_after"
return "full"


def resolve_set_command(config: Config, run_mode: str) -> ResolvedCommand:
"""Return resolved metadata for the selected set command mode."""
set_options = set_command_options(config)
Expand Down Expand Up @@ -790,13 +845,16 @@ def run_command(
run_restore(config, client, sourcegraph_site_config, run_paths, worker_pool)
return command_data
else:
# Standalone command: the config's user filters (or --full) choose
# between a scoped and a full org sync.
run_sync_saml_organizations(
config,
client,
sourcegraph_site_config,
command_data,
run_paths,
worker_pool,
standalone=True,
)
return command_data

Expand Down Expand Up @@ -883,8 +941,16 @@ def run_sync_saml_organizations(
command_data: run_context.CommandData,
run_paths: backups.RunPaths,
worker_pool: ThreadPoolExecutor,
*,
standalone: bool = False,
) -> None:
"""Run the selected SAML organization sync command."""
"""Run the selected SAML organization sync command.

Only the standalone command forwards the config's user filters: in
combined `set ... --sync-saml-orgs` runs those filters belong to the
set phase, which already hands over its selected users via
`command_data`.
"""
organizations_command.cmd_sync_saml_organizations(
client,
run_paths,
Expand All @@ -895,6 +961,10 @@ def run_sync_saml_organizations(
),
do_backup=not config.no_backup,
command_data=command_data,
user_identifiers=config.users if standalone else (),
users_without_explicit_perms=(config.users_without_explicit_perms if standalone else False),
user_created_after=config.created_after if standalone else None,
explicit_permissions_batch_size=config.explicit_permissions_batch_size,
worker_pool=worker_pool,
)

Expand Down
46 changes: 46 additions & 0 deletions src/src_auth_perms_sync/orgs/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,52 @@

from __future__ import annotations

QUERY_CURRENT_USER = """
query SamlOrganizationSyncCurrentUser {
currentUser { id username }
}
"""

# One search request finds every tool-managed (`synced-` prefixed) org.
# totalCount > len(nodes) signals truncation; callers must then fall back
# to per-name lookups.
QUERY_SYNCED_ORGANIZATIONS = """
query SyncedOrganizations($first: Int!, $query: String!) {
currentUser { id username }
organizations(first: $first, query: $query) {
totalCount
nodes {
id
name
}
}
}
"""


def users_organizations_batch_query(batch_size: int) -> str:
"""Fetch many users' org memberships in one aliased `node()` request.

Used to validate a scoped org sync: re-reading each scoped user's own
org list is far cheaper than paging every touched org's member list.
"""
variables = ", ".join(f"$user{index}: ID!" for index in range(batch_size))
aliases = "".join(
f"""
user{index}: node(id: $user{index}) {{
... on User {{
id
username
organizations {{
nodes {{ id name }}
}}
}}
}}"""
for index in range(batch_size)
)
return f"query UsersOrganizationsBatch({variables}) {{{aliases}\n}}"


QUERY_ORGANIZATION_MEMBERS_PAGE = """
query OrganizationMembersPage($id: ID!, $first: Int!, $after: String) {
node(id: $id) {
Expand Down
Loading