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
8 changes: 0 additions & 8 deletions dev/TODO.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
# TODO

## Decide: pendingBindIDs / usersWithPendingPermissions

The CLI cannot create pending permissions (it validates users exist), but
snapshots record `pending_bindIDs`, and setup.py / the live hygiene check
report (never delete) any that appear. Decide whether "grant before first
login" is a customer need; if not, consider dropping the snapshot field.
See the thread discussion 2026-06-11.

## High priority: Remote trigger on demand

- Sourcegraph webhook for new user coming in v7.4.0
Expand Down
64 changes: 62 additions & 2 deletions src/src_auth_perms_sync/permissions/full_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,49 @@ def _filter_full_set_plans(
)


def _overwrites_with_preserved_pending(
overwrites: list[permission_types.RepositoryUsernameOverwrite],
pending_bind_ids_by_repository_id: dict[str, list[str]],
) -> list[permission_types.RepositoryUsernameOverwrite]:
"""Resend each repo's pending bindIDs so overwrites don't delete them.

`setRepositoryPermissionsForUsers` replaces a repo's whole explicit
list — real grants AND pending ones. Appending the repo's current
pending bindIDs to the payload re-creates the same pending rows in the
same transaction, so the script neither creates nor loses them.
"""
if not pending_bind_ids_by_repository_id:
return overwrites
preserved_overwrites: list[permission_types.RepositoryUsernameOverwrite] = []
preserved_repo_count = 0
preserved_grant_count = 0
for overwrite in overwrites:
pending_bind_ids = [
bind_id
for bind_id in pending_bind_ids_by_repository_id.get(overwrite.repository_id, [])
if bind_id not in overwrite.usernames
]
if not pending_bind_ids:
preserved_overwrites.append(overwrite)
continue
preserved_repo_count += 1
preserved_grant_count += len(pending_bind_ids)
preserved_overwrites.append(
permission_types.RepositoryUsernameOverwrite(
repository_id=overwrite.repository_id,
repository_name=overwrite.repository_name,
usernames=overwrite.usernames + tuple(pending_bind_ids),
)
)
if preserved_repo_count:
log.info(
"Preserving %d pending bindID grant(s) across %d repo(s) in overwrite payloads.",
preserved_grant_count,
preserved_repo_count,
)
return preserved_overwrites


def _write_full_set_before_snapshot(
input_path: Path,
timestamp: str,
Expand Down Expand Up @@ -663,7 +706,12 @@ def _finish_full_set_apply_with_backup(
permission_snapshot.render_snapshot_diff(before_snapshot, after_snapshot),
)

validate_post_apply(after_snapshot, plan.expected_users, set(plan.expected_users))
validate_post_apply(
after_snapshot,
plan.expected_users,
set(plan.expected_users),
expected_pending_users=before_snapshot["pending_users"],
)
log.info(
"To roll back the explicit-permissions state captured in "
"the before-snapshot, run:\n"
Expand Down Expand Up @@ -939,9 +987,21 @@ def _run_full_set_apply(
else:
before_timestamp = backups.backup_timestamp()

# The before-snapshot's pending grants are already scoped to any repo
# selection; without one (--no-backup), fetch the live pending state so
# the overwrites still preserve it.
if snapshot_state.before_snapshot is not None:
pending_users = snapshot_state.before_snapshot["pending_users"]
else:
pending_users = permissions_sourcegraph.list_pending_users_with_repos(client)
overwrites = _overwrites_with_preserved_pending(
filtered_plans.overwrites,
permission_snapshot.pending_bind_ids_by_repository_id(pending_users),
)

apply_result = _apply_full_set_plans(
client,
filtered_plans.overwrites,
overwrites,
filtered_plans.skipped_repo_ids,
parallelism,
worker_pool,
Expand Down
25 changes: 20 additions & 5 deletions src/src_auth_perms_sync/permissions/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,28 @@ def query_user_by_id(
}
"""

# Used as part of post-apply validation: any of OUR bindIDs appearing in
# this list means the bindID didn't resolve to a real user (typically a
# username typo or a recent rename — would fail for our case since we
# only ever pass usernames the script already enumerated from the users
# query).
# Explicit-API grants whose bindID didn't resolve to a real user yet
# ("grant before first login"). Snapshots capture them so set/restore can
# preserve them, and post-apply validation checks none of OUR usernames
# landed here (which would mean a write didn't bind to a real user).
QUERY_PENDING_BINDIDS = """
query PendingBindIDs {
usersWithPendingPermissions
}
"""

# For a bindID with no matching user, this resolver falls back to the
# pending-permissions store and returns the repos the bindID is pending
# on ("late binding" — see the GraphQL schema comment). That fallback is
# the only API that exposes WHICH repos a pending bindID has.
QUERY_PENDING_USER_REPOS = """
query PendingUserRepos($bindID: String!, $first: Int!, $after: String) {
authorizedUserRepositories(username: $bindID, first: $first, after: $after) {
nodes {
id
name
}
pageInfo { hasNextPage endCursor }
}
}
"""
65 changes: 47 additions & 18 deletions src/src_auth_perms_sync/permissions/restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,37 +578,66 @@ def _capture_restore_snapshot_state(


def plan_full_restore(snapshot_state: RestoreSnapshotState) -> RestorePlan:
"""Build only the per-repo overwrite plans needed to match the snapshot."""
target_repos = snapshot_state.target_snapshot["repos"]
current_repos = snapshot_state.current_snapshot["repos"]
"""Build only the per-repo overwrite plans needed to match the snapshot.

Each overwrite carries the target's real usernames PLUS the target's
pending bindIDs for that repo: `setRepositoryPermissionsForUsers`
replaces both kinds in one transaction, and unresolved bindIDs become
pending rows again — restoring pending grants exactly as captured.
"""
target_snapshot = snapshot_state.target_snapshot
current_snapshot = snapshot_state.current_snapshot
target_repos = target_snapshot["repos"]
current_repos = current_snapshot["repos"]
target_pending = permission_snapshot.pending_bind_ids_by_repository_id(
target_snapshot["pending_users"]
)
current_pending = permission_snapshot.pending_bind_ids_by_repository_id(
current_snapshot["pending_users"]
)
pending_repository_names = {
**permission_snapshot.pending_repository_names_by_id(current_snapshot["pending_users"]),
**permission_snapshot.pending_repository_names_by_id(target_snapshot["pending_users"]),
}

def repository_name(repo_id: str) -> str:
for repos in (target_repos, current_repos):
repo_snapshot = repos.get(repo_id)
if repo_snapshot is not None:
return repo_snapshot["name"]
return pending_repository_names[repo_id]

overwrites: list[permission_types.RepositoryUsernameOverwrite] = []
skipped_repo_count = 0
for repo_id, repo_snapshot in target_repos.items():
target_usernames = repo_snapshot["users"]
planned_repo_ids = (
set(target_repos) | set(current_repos) | set(target_pending) | set(current_pending)
)
extra_repo_ids = planned_repo_ids - set(target_repos) - set(target_pending)
for repo_id in sorted(planned_repo_ids, key=repository_name):
target_repo = target_repos.get(repo_id)
target_usernames = list(target_repo["users"]) if target_repo else []
current_repo = current_repos.get(repo_id)
current_usernames = current_repo["users"] if current_repo else []
if current_usernames == target_usernames or sorted(current_usernames) == target_usernames:
target_pending_bind_ids = target_pending.get(repo_id, [])
usernames_match = (
current_usernames == target_usernames or sorted(current_usernames) == target_usernames
)
if usernames_match and current_pending.get(repo_id, []) == target_pending_bind_ids:
skipped_repo_count += 1
continue
pending_bind_ids = [
bind_id for bind_id in target_pending_bind_ids if bind_id not in target_usernames
]
overwrites.append(
permission_types.RepositoryUsernameOverwrite(
repository_id=repo_id,
repository_name=repo_snapshot["name"],
usernames=tuple(target_usernames),
)
)
extra_repo_ids = set(current_repos) - set(target_repos)
for repo_id in sorted(extra_repo_ids):
overwrites.append(
permission_types.RepositoryUsernameOverwrite(
repository_id=repo_id,
repository_name=current_repos[repo_id]["name"],
usernames=(),
repository_name=repository_name(repo_id),
usernames=tuple(target_usernames) + tuple(pending_bind_ids),
)
)
return RestorePlan(
overwrites=overwrites,
snapshot_repo_count=len(target_repos),
snapshot_repo_count=len(set(target_repos) | set(target_pending)),
extra_repo_count=len(extra_repo_ids),
skipped_repo_count=skipped_repo_count,
)
Expand Down
Loading