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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,24 +165,24 @@ for the Builder's External-only filter, and reading the connected admin and its
| Remove group member | Groups, Users, Builder | **confirmed** |
| Add delegate | Users, Offboarding, Builder | **confirmed** |
| Remove delegate | Users, Builder | **confirmed** |
| Set vacation (auto-reply) | Users, Offboarding, Builder | **confirmed**, including the explicit `contactsonly false domainonly false` and dated `start`/`end` the user page sends; offboarding's "starts now, no end" `start Started end NotSpecified`, not yet |
| Set vacation (auto-reply) | Users, Offboarding, Builder | **confirmed**, including the explicit `contactsonly false domainonly false` and dated `start`/`end` the user page sends; and offboarding's "starts now, no end" `start Started end NotSpecified` (the first live offboarding, 2026-10-07, GAM 7.48.14) |
| Clear vacation | Users, Builder | **confirmed** |
| Reset password (it runs no sign-out: offboarding's next step does, or *Sign out everywhere*) | Offboarding, Builder | **confirmed** |
| Sign out everywhere | Users, Builder | not yet |
| Revoke access: app passwords, backup codes, OAuth tokens, and sign out (`deprovision signout`) | Offboarding | not yet |
| Revoke access: app passwords, backup codes, OAuth tokens, and sign out (`deprovision signout`) | Offboarding | **confirmed** (the first live offboarding, 2026-10-07, GAM 7.48.14) |
| Suspend / unsuspend | Users (both), Builder (suspend) | not yet |
| Delete an account | Users, Builder | not yet |
| Undelete an account | Builder | not yet |
| Transfer Drive + calendar data | Offboarding, Builder | **confirmed** as two calls; the single call offboarding now makes (with `all`: private and shared Drive files) is not yet |
| Remove the leaver from everyone's calendars | Offboarding | not yet |
| Transfer Drive + calendar data | Offboarding, Builder | **confirmed**: as two calls, and the single call offboarding makes (`drive,calendar … all`) was accepted (the first live offboarding, 2026-10-07, GAM 7.48.14); that the transfer *completed* and moved every file is not yet recorded |
| Remove the leaver from everyone's calendars | Offboarding | **confirmed** the shape (the first live offboarding, 2026-10-07, GAM 7.48.14): GAM visited every user and ended, as expected, on the leaver's own calendar ("Cannot change your own access level", exit 50, tolerated); that a colleague's share was removed is not yet recorded |
| Add a calendar event (the manager's reminder) | Offboarding | **confirmed**; with an invitee it now adds `sendupdates all` (so they get the invitation email), not yet run live |
| Share a calendar (add an ACL) | Calendars, Users (their own calendar) | **confirmed** |
| Subscribe someone, so a shared calendar appears | Calendars, Onboard | **confirmed** |
| Share with a group, subscribing each member | Calendars | not yet — each call it makes is confirmed, the expansion is not |
| Unshare a calendar (remove an ACL) | Calendars, Users | not yet |
| Delete an event | Calendars | not yet |
| Delete a secondary calendar | Calendars | **confirmed** |
| Forwarding: add an address, forward on / off | Builder (forward off also Offboarding) | not yet |
| Forwarding: add an address, forward on / off | Builder (forward off also Offboarding) | forward off **confirmed** (the first live offboarding, 2026-10-07, GAM 7.48.14); adding an address and forward on, not yet |
| Create / delete an alias | Builder | not yet |
| Create a group | Builder | not yet |
| Export a result to a Google Sheet | Builder | not yet |
Expand Down
19 changes: 15 additions & 4 deletions docs/domains/lifecycle-offboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,23 @@ one run per leaver (`test_offboard_refuses_a_second_run_for_a_leaver_whose_offbo
and the preview's commands: each step's exact `gam` line in the page, and the previewed argv = what the mock received
(`test_offboard_preview_commands_are_what_runs`).
**Not proven offline** (the mock lies): a live DTS transfer of a real user's Drive+Calendar, the
actual per-user calendar sweep at domain scale, `deprovision signout` and `forward off` (never run
live: their output, and `deprovision` for a user with no app passwords or tokens), and `delete_user`
itself. Per CLAUDE.md, these must be run against a **throwaway** account before being trusted.
actual per-user calendar sweep at domain scale, `deprovision signout` and `forward off`, and
`delete_user` itself. Per CLAUDE.md, these must be run against a **throwaway** account before being trusted.
**First live run (2026-10-07, GAM 7.48.14, a real leaver, plan D8):** all eight steps `ok` in Audit,
"8 of 8". Confirmed: `deprovision signout`, `forward off`, the auto-reply's `start Started end
NotSpecified`, and the single `create datatransfer … drive,calendar … all` call (accepted; completion
not recorded). The sweep visited every user and ended exit 50 on the leaver's own primary calendar,
"Cannot change your own access level" — `all users` includes the still-active leaver, so this happens
on **every** offboarding and is tolerated as `OWN_ACL` (the mock's default sweep now does the same;
`SWEEPCLEAN` keeps the exit-0 path for a leaver suspended first). Still unproven live: that the
transfer completed with nothing left owned, that a colleague's share was removed (only the last
stderr line of each kind is audited), the reminder with an invitee (`sendupdates all`), and
`delete_user`.

## First live run checklist
Offboarding a real user is the live test (plan D8). Keep this page open.
Offboarding a real user is the live test (plan D8; first run 2026-10-07, all eight `ok` — see
"Testing / live-verification status" for what it proved and what is still open). Keep this page open
for the next one: the after-run checks in Google are what remain.

**In the preview, before Run**
- The header names the right person and says "8 steps" (nothing ticked as already done).
Expand Down
9 changes: 6 additions & 3 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ transfer has finished.

**Before:** enter the leaver and the manager and click **Preview steps**. Read every warning. GamGUI
refuses to offboard the admin it is connected as — revoking that account's access would cut off
GamGUI mid-run — so connect as a different super admin first if you need to. Several steps, and
the routine as a whole, have not yet run on a live domain, so **work through the
GamGUI mid-run — so connect as a different super admin first if you need to. The routine has run
live once, every step succeeding; a few effects (the transfer finishing, a colleague's calendar share
removed) are still unconfirmed, so **work through the
[first live run checklist](domains/lifecycle-offboarding.md#first-live-run-checklist)** with it
open: what to look at in the preview, and what to verify in Google afterwards.

Expand All @@ -127,7 +128,9 @@ Anything beyond these steps, like listing the leaver's Drive files first, is a r
**While it runs:** the calendar sweep can take minutes. Don't quit the app; to halt it, use
**Stop** (see [Jobs](#jobs-and-stop)).

**After:** the panel should say "Offboarding complete — 8 of 8 steps succeeded". If a step failed,
**After:** the panel should say "Offboarding complete — 8 of 8 steps succeeded". In **Audit**, the
calendar clean-up's record shows "Cannot change your own access level": that is GAM declining to change
the leaver's own calendar, which it visits too, and is expected on every run. If a step failed,
its line says why and later steps that depended on it were not run. Fix the cause, tick the steps
that already succeeded, preview again and run — the checklist covers each failure.

Expand Down
7 changes: 4 additions & 3 deletions docs/plans/2026-09-23-ten-out-of-ten.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,10 @@ plan's Phase 0 F1–F3.
that ends at the guide; `make setup`'s interpreter choice stated as it is; screenshots regenerated
with the tenant header). **Open:** V1; V3–V5 are new
scope, and D6 says none for now.
- **Phase 8** — **open** (D8). The operator's first real offboarding is its first live test: README
"Live verification status" marks every step still unproven, and
`docs/domains/lifecycle-offboarding.md` has the first live run checklist.
- **Phase 8** — **mostly done** (D8). The operator's first real offboarding ran 2026-10-07, all eight
steps `ok`; README "Live verification status" and the offboarding runbook record what it confirmed.
Open: the transfer's completion, a colleague's share removed, the reminder with an invitee, and the
account delete.

**Beyond the plan**, also on the branch (each has a failure-log entry): every confirm step runs what
its preview held, under a single-use token — offboarding, signatures, onboarding, bulk department,
Expand Down
6 changes: 3 additions & 3 deletions docs/plans/2026-10-02-configurable-offboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ the leaver group by group, the memberships recorded in the preview so it can be

## Before P2 ships

Offboarding has never run on a real user (README "Live verification status", runbook "first live run
checklist"). Prove today's eight on a throwaway user first, so new steps aren't layered on unproven
ones.
Done in part: the operator's first live offboarding (2026-10-07) ran all eight `ok`. Still open
before relying on new steps: the transfer's completion and a colleague's share actually removed (the
runbook's after-run checks).

## Status by item

Expand Down
10 changes: 9 additions & 1 deletion tests/fixtures/mock_gam.sh
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,10 @@ if [ "${1:-}" = "all" ] && [ "${2:-}" = "users" ] && [ "${3:-}" = "delete" ] &&
scope="$1"
case "$scope" in
*SWEEPSLOW*) exec sleep 30 ;;
*SWEEPCLEAN*)
# A leaver suspended before offboarding is not among `all users`: no own calendar visited, exit 0.
printf 'User: alice@example.com, Calendar: alice@example.com, Calendar ACL: (Scope: user:%s), Deleted\n' "$scope"
exit 0 ;;
*OWNACL*)
printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2
exit 50 ;;
Expand All @@ -951,8 +955,12 @@ if [ "${1:-}" = "all" ] && [ "${2:-}" = "users" ] && [ "${3:-}" = "delete" ] &&
printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2
exit 50 ;;
esac
# Seen live (2026-10-07, GAM 7.48.14): `all users` includes the leaver, who is still active after the
# reset, so every sweep ends on their own primary calendar, which GAM refuses to change — exit 50 with
# this line, tolerated as OWN_ACL. A clean exit 0 never happens for an active leaver.
printf 'User: alice@example.com, Calendar: alice@example.com, Calendar ACL: (Scope: user:%s), Deleted\n' "$scope"
exit 0
printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2
exit 50
fi

# `gam user <owner> remove calendars <UserCalendarEntity>` -> PERMANENTLY delete a secondary calendar.
Expand Down
15 changes: 10 additions & 5 deletions tests/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,13 +283,18 @@ async def test_offboard_calendar_sweep_tolerates_own_acl(connector):

@pytest.mark.asyncio
async def test_offboard_calendar_sweep_clean_success_and_real_failure(connector):
# The sweep's exit-0 path (the mock used to fail it unconditionally, so it was never exercised)
# and a failure the connector must NOT tolerate (a missing scope is not a per-entity notice).
ok = await connector.remove_from_all_calendars("leaver@example.com")
assert ok.ok and not ok.detail
# The live run (2026-10-07): an active leaver is among `all users`, so the sweep always ends on their
# own calendar, exit 50 "Cannot change your own access level" — tolerated, and audited as such.
live = await connector.remove_from_all_calendars("leaver@example.com")
assert live.ok and "best-effort" in (live.detail or "")
rec = connector.audit.tail()[-1]
assert rec["ok"] is True and rec["argv"] == ["all", "users", "delete", "calendaracls", "primary", "leaver@example.com"]
assert "tolerated" not in rec.get("extra", {})
assert rec["extra"]["tolerated"] is True and "own access level" in rec["extra"]["error"]
# The exit-0 path (a leaver suspended first, so never visited), and a failure the connector must NOT
# tolerate (a missing scope is not a per-entity notice).
ok = await connector.remove_from_all_calendars("SWEEPCLEAN-leaver@example.com")
assert ok.ok and not ok.detail
assert "tolerated" not in connector.audit.tail()[-1].get("extra", {})

bad = await connector.remove_from_all_calendars("SWEEPFAIL@example.com")
assert not bad.ok and "insufficient authentication scopes" in bad.detail
Expand Down
13 changes: 12 additions & 1 deletion tests/test_mock_gam.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
C.subscribe_calendar("bob@example.com", CAL, selected=False)],
"remove_calendar": [C.remove_calendar("alice@example.com", CAL)],
"delete_event": [C.delete_event(CAL, "evt-1")],
"remove_all_calendar_acls": [C.remove_all_calendar_acls("leaver@example.com")],
# A leaver suspended first (SWEEPCLEAN): an active one ends exit 50 on their own calendar (a test below).
"remove_all_calendar_acls": [C.remove_all_calendar_acls("SWEEPCLEAN-leaver@example.com")],
"add_calendar_event": [C.add_calendar_event("mgr@example.com", "Confirm", "2026-07-23", "2026-07-24"),
C.add_calendar_event("mgr@example.com", "Confirm", "2026-07-23", "2026-07-24",
description="d", attendee="it@example.com")],
Expand Down Expand Up @@ -131,6 +132,16 @@ async def test_mock_accepts_every_shape_the_app_emits(runner, domain, argv):
await runner.run_authenticated(domain, argv, serialize=True)


async def test_mock_sweep_of_an_active_leaver_ends_on_their_own_calendar_as_live_gam_did(runner, domain):
# Live, 2026-10-07 (GAM 7.48.14): `all users` includes the still-active leaver, so the sweep's last
# word is GAM refusing to change their own primary calendar — exit 50, tolerated by the connector.
with pytest.raises(GAMError) as err:
await runner.run_authenticated(domain, C.remove_all_calendar_acls("leaver@example.com"), serialize=True)
assert err.value.exit_code == 50 and err.value.kind == GAMErrorKind.OWN_ACL
assert ("Calendar: leaver@example.com, Calendar ACL: (Scope: user:leaver@example.com), "
"Delete Failed: Cannot change your own access level.") in err.value.stderr


@pytest.mark.hand_built_argv # partial `vacation` shapes (set_vacation names every field) to seed the merge
async def test_mock_vacation_merges_like_gam(runner, domain, gam_state):
# GAM's setVacation updates only the fields the command names; a stateless mock (or one that
Expand Down
Loading