PHASE 9 OF 14

Organization & Team Management

Org structure and nested teams, the five permission levels and how they map to real roles, SSO enforcement, GitHub Apps vs OAuth Apps, fine-grained PATs, org-level rulesets, Enterprise Managed Users, audit log streaming, and billing governance — the decisions that define how your engineering org operates at scale

Org Management Teams SSO GitHub Apps Fine-Grained PAT Audit Log EMU
9.1

Organization Structure: Members, Collaborators & Team Hierarchy

GitHub orgs have three categories of people and a flexible team hierarchy. Getting this structure right reduces access control friction and makes CODEOWNERS-enforced reviews tractable at scale.

People categories

CategoryWhoBilling seat?Can be in teams?
OwnerFull admin access to the org; can add/remove members, configure SSO, manage billingYesYes
MemberStandard org member; access determined by team membership and repo permissionsYesYes
Outside collaboratorExternal contributor added directly to specific repos only; not a full org member; no access to org settings or team discussionsYes (for private repos)No
GitHub Apps / botsMachine accounts; installation-based permissions; not a seatNoNo

Team hierarchy

Teams can be nested — child teams inherit their parent's repo permissions in addition to their own. This maps naturally to an engineering org chart:

acme (organisation) ├── @acme/engineering ← parent: write to all repos │ ├── @acme/platform-team ← child: admin to infra/* repos │ │ ├── alice (maintainer) │ │ └── bob │ ├── @acme/payments-team ← child: write to payments/* repos │ │ └── carol │ └── @acme/security-team ← child: CODEOWNER on all .github/ dirs └── @acme/contractors ← separate top-level: triage only └── dave (outside collaborator)

Managing teams via CLI

# List all teams in the org $ gh api orgs/acme/teams --paginate --jq '.[].slug' # Add a member to a team $ gh api orgs/acme/teams/payments-team/memberships/carol \ --method PUT --field role=member # List repos a team has access to $ gh api orgs/acme/teams/payments-team/repos --paginate \ --jq '.[] | {name: .name, permission: .permissions}' # Sync team membership from a CSV (custom onboarding script pattern) $ cat team-members.csv | while IFS=, read user team; do gh api orgs/acme/teams/$team/memberships/$user --method PUT --field role=member done
OUTSIDE COLLABORATORS AT SCALE
Outside collaborators are added to individual repos — not the org. When someone leaves, you must remove them from every repo they were added to individually. For contractors who need access to many repos, it's cleaner to create a dedicated team like @acme/contractors with limited permissions, add them as org members, and control access via the team. Removing from the team removes access to all repos at once.
9.2

Team Permissions: The Five Levels Mapped to Real Roles

GitHub's five permission levels are often misunderstood. Here's what each actually enables — and which real engineering role it maps to.

LevelKey capabilitiesReal role
Read Clone, pull, view issues and PRs, comment on issues, open issues Product managers, designers, stakeholders, documentation contributors, security auditors
Triage Everything in Read + manage (label, close, assign, reopen) issues and PRs — but cannot push code or merge QA engineers doing bug triage, developer advocates managing community issues, support teams
Write Everything in Triage + push to branches, create and approve PRs, manage Actions runs, create releases Software engineers — the default for all developers contributing to the codebase
Maintain Everything in Write + manage repo settings (topics, description, merge options), push to protected branches (without bypassing rules), manage webhooks, manage GitHub Pages Tech leads, senior engineers owning a service — can configure the repo without full admin
Admin Everything + delete the repo, transfer the repo, force-push to any branch, bypass branch protection, manage access, manage secrets Platform team, engineering managers — use sparingly; prefer Maintain for day-to-day leads
ADMIN CREEP
The most common org hygiene problem is too many people with Admin. Admin can delete repos, bypass all branch protection rules, and expose all secrets. Audit admin access quarterly. Most tech leads only need Maintain — they can configure the repo, manage webhooks, and update topics without the destructive powers of Admin.

Setting team repo access

# Grant a team Write access to a repo $ gh api orgs/acme/teams/payments-team/repos/acme/payment-service \ --method PUT --field permission=push # permission values: pull=Read, triage=Triage, push=Write, maintain=Maintain, admin=Admin # Audit: who has Admin on a specific repo? $ gh api repos/acme/payment-service/collaborators \ --jq '.[] | select(.permissions.admin == true) | .login'
9.3

SSO: Enforcing SAML/OIDC, Token Authorization & PAT Policy

SSO (Single Sign-On) ties GitHub identity to your corporate identity provider (Okta, Azure AD, Google Workspace). Enforcing SSO means a user removed from your IdP loses GitHub org access automatically — no manual deprovisioning required.

SAML SSO vs OIDC SSO

SAML SSOOIDC SSO (Enterprise)
ProvisioningManual invite + SCIM for auto-provisioning (requires a SCIM-capable IdP)Automatic — accounts are provisioned/deprovisioned by the IdP (EMU only)
Token auth after SSOPATs and SSH keys must be explicitly authorized for the SSO org via the UITokens are scoped to the managed user's identity — no separate authorization step
Existing accountsUsers link their existing GitHub account to the SSO identityEMU: GitHub creates new managed accounts; users cannot use personal GitHub accounts
AvailabilityGitHub Teams and EnterpriseGitHub Enterprise (EMU only)

Enforcing SAML SSO

Org Settings → Authentication security → Enable SAML single sign-on → Require SAML SSO authentication. After enforcement:

  • Any org member who hasn't authenticated via SSO is automatically removed from the org
  • Existing PATs and SSH keys that haven't been SSO-authorized stop working for org resources
  • OAuth Apps require SSO authorization per org

PAT and SSH key authorization after SSO

After SSO is enforced, users must re-authorize their existing credentials:

# User flow: authorize a PAT for an SSO org # Settings → Developer settings → Personal access tokens → Configure SSO → Authorize for acme org # Or via API — check which orgs a token is authorized for $ gh api user/installations --jq '.[].account.login' # Audit: tokens NOT authorized for SSO (admin view) $ gh api orgs/acme/credential-authorizations --paginate \ --jq '.[] | select(.token_last_eight != null) | {login, credential_type, authorized_credential_expires_at}'

SCIM provisioning

SCIM (System for Cross-domain Identity Management) automates user and team provisioning from your IdP to GitHub. When an employee joins, their IdP group membership creates their GitHub team membership. When they leave, IdP deactivation removes GitHub access — no manual offboarding step.

TEAM SYNC
With SCIM + team sync configured, create GitHub teams that mirror your IdP groups. The mapping is maintained automatically: add someone to the "payments-engineers" group in Okta and they appear in @acme/payments-team on GitHub within minutes. This eliminates the manual "add to GitHub team" step in your onboarding runbook.
9.4

OAuth Apps vs GitHub Apps: Permission Model & When to Build a GitHub App

Both OAuth Apps and GitHub Apps let external services interact with GitHub on behalf of users or repos. They have fundamentally different permission models — understanding the difference is essential when building integrations or auditing what has access to your org.

OAuth AppGitHub App
Acts asA specific user — calls the API as that user, using their permissionsAn independent identity — calls the API as itself (installation token) or on behalf of a user (user access token)
Permission scopeCoarse OAuth scopes (repo, read:org, etc.) — all-or-nothing per scopeFine-grained per-resource permissions (e.g. read pull requests on specific repos only)
InstallationPer-user authorization; the app has access wherever the authorizing user has accessPer-repo or per-org installation; owner explicitly chooses which repos the app can access
TokensLong-lived user OAuth tokens (no automatic expiry)Short-lived installation tokens (expire in 1 hour); JWT for app-level auth
Rate limitingShared with the authorizing user (5,000 req/hr)Higher limits per installation (up to 15,000 req/hr for larger orgs)
Webhook deliveryRepo-level webhooks onlyCan subscribe to all events across all installed repos from a single endpoint
Best forSimple personal tools; quick integrations where fine-grained control isn't criticalProduction integrations, CI/CD tools, bots — anything that should be auditable and follow least-privilege

When to build a GitHub App (vs use a PAT)

  • The integration needs to act across multiple repos or the entire org
  • You need webhook events from many repos in a single endpoint
  • The action should be attributed to a bot/service, not a human user
  • You need finer permission granularity than OAuth scopes provide
  • Compliance requires auditable, revocable, short-lived credentials

GitHub App installation token flow

GitHub App (has a private key) ↓ signs a JWT (valid 10 min) POST /app/installations/{id}/access_tokens ↓ exchanges JWT for installation token Installation token (valid 1 hour, scoped to selected repos) ↓ use in API calls / as GITHUB_TOKEN in Actions API call → GitHub (attributed to the app, not a user)

Using a GitHub App token in Actions

- name: Get GitHub App installation token id: token uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Use the token run: gh api repos/acme/platform/issues --header "Authorization: Bearer $TOKEN" env: TOKEN: ${{ steps.token.outputs.token }}
9.5

Fine-Grained Personal Access Tokens

Classic PATs were all-or-nothing: a repo scope token had full read/write access to every private repo the user could access, forever (no expiry). Fine-grained PATs (introduced 2022) fix both problems.

Fine-grained PAT vs classic PAT

Classic PATFine-Grained PAT
Repo scopeAll repos the user can access, or all public reposSpecific repos only — you select which ones
Permission granularityCoarse OAuth scopes (repo, gist, read:org…)Per-resource permissions (e.g. read-only on pull requests of repo X)
ExpiryOptional (often set to "no expiry")Mandatory — max 1 year; org policy can require shorter
Org approvalNo approval requiredOrg owners can require approval before a fine-grained PAT can access org resources
SSO supportMust be separately authorized for SSO orgsOrg approval covers SSO authorization

Creating a fine-grained PAT

Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token. Key fields:

  • Resource owner: your personal account, or an org (requires org approval if org has approval policy)
  • Repository access: All repositories, or selected repositories (always choose selected for least privilege)
  • Permissions: 30+ permission categories, each settable to No access / Read-only / Read and write

Org policy for fine-grained PATs

# Org Settings → Personal access tokens → Fine-grained tokens # Options: # "Allow access via fine-grained PATs" — enabled by default # "Require approval of fine-grained PATs" — recommended for regulated orgs # "Restrict fine-grained PAT permissions" — set max permission level # Review pending PAT requests (as org admin) $ gh api orgs/acme/personal-access-token-requests --paginate \ --jq '.[] | {id, owner: .owner.login, repos: [.repositories[].name], permissions}' # Approve a request $ gh api orgs/acme/personal-access-token-requests/42 \ --method POST --field action=approve # Revoke all PATs for a departing employee $ gh api orgs/acme/members/dave/tokens --method DELETE
MIGRATION STRATEGY
Don't mandate an immediate switch from classic to fine-grained PATs — it breaks existing automations. Instead: (1) Enable the "Require approval" policy for new fine-grained PATs to establish oversight. (2) Set a classic PAT expiry policy (90 days) to force rotation and create natural opportunities to migrate. (3) When a classic PAT is rotated, replace it with a fine-grained equivalent scoped to only what's needed.
9.6

Org-Level Rulesets: Baseline Protection Across All Repos

Org-level rulesets (covered partially in Phase 2 and 3) let an org owner enforce branch protection rules across all repos — including ones created in the future — without touching each repo individually. This is the most scalable way to ensure a security baseline.

Common org-level ruleset policies

# Policy 1: No direct pushes to main across ALL repos { "name": "org-baseline-main-protection", "target": "branch", "enforcement": "active", "conditions": { "repository_name": {"include": ["~ALL"], "exclude": ["~ARCHIVED"]}, "ref_name": {"include": ["refs/heads/main", "refs/heads/master"]} }, "rules": [ {"type": "deletion"}, {"type": "non_fast_forward"}, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true } } ], "bypass_actors": [ {"actor_id": 12345, "actor_type": "Integration", "bypass_mode": "always"} ] }

Targeting repos by topic

Use repo topics as a lightweight feature flag for rolling out stricter rulesets:

# Tag a repo with a topic to include it in stricter ruleset $ gh api repos/acme/payment-service/topics \ --method PUT --field names='["security-critical","pci-scope"]' # Create a stricter ruleset that targets only pci-scope repos # Conditions: repository_property: {name: "topic", property_value: ["pci-scope"]}

Required workflows (Enterprise)

An org-level ruleset can require a specific reusable workflow to pass on all PRs, across all repos. This is the mechanism for platform teams to enforce standards that individual teams can't opt out of:

# Required workflow rule in the org ruleset: { "type": "workflows", "parameters": { "workflows": [ { "repository_id": 98765, // platform-workflows repo "path": ".github/workflows/security-baseline.yml", "ref": "refs/heads/main" } ] } } // This forces CodeQL + secret scanning + dependency review on every PR // across every repo in the org — teams cannot disable it
9.7

Enterprise Managed Users (EMU): What Changes for Developers

Enterprise Managed Users (EMU) is GitHub's fully IdP-controlled identity model. Instead of developers using their personal GitHub accounts in the org, the IdP creates dedicated managed accounts for them. Everything about those accounts is controlled by the enterprise.

Key differences for developers

AspectRegular GitHub orgEMU org
Account ownershipDeveloper owns their personal GitHub account; org is attached to itEnterprise owns the managed account; it ceases to exist when the employee leaves
Username formatDeveloper's chosen username (alice)Prefixed with enterprise slug (alice_acme)
ContributionsContributions show on personal profile and persist after leavingContributions are tied to the managed account; they don't follow the developer
Personal projectsDeveloper can have personal repos alongside work repos on the same accountManaged accounts cannot create personal public repos or interact with non-enterprise public repos
PATs / SSH keysDeveloper manages their own credentialsAdmin controls allowed credential types and expiry; org policy enforced centrally
Access terminationMust manually remove from org when employee leavesIdP deprovisioning immediately removes all access and suspends the managed account
DEVELOPER EXPERIENCE IMPACT
EMU significantly restricts developer autonomy on GitHub. Developers who contribute to open-source projects, maintain personal portfolios, or use GitHub for personal projects will need a separate personal GitHub account alongside their managed work account. This is a meaningful quality-of-life tradeoff — communicate it clearly during adoption. Many teams use regular SAML SSO + SCIM (which is less restrictive) unless they have specific compliance requirements that mandate EMU.
9.8

Org Audit Log: What's Logged & Streaming to SIEM

The org audit log records every significant action taken by org members — configuration changes, access grants, repo operations, secret management, and more. It's your forensic trail for security investigations and compliance audits.

Key event categories

CategoryExamples of logged events
reporepo created, deleted, transferred, visibility changed, forked
teamteam created, member added/removed, repo permission changed
orgmember invited/removed, SSO enabled/disabled, IP allow list changed
secret_scanningalert created, dismissed, bypass used, push protection enabled
protected_branchbranch protection created/updated/deleted, rule bypassed
workflowsworkflow run created, approval given, secret accessed during run
packagespackage published, version deleted, package transferred
oauth_applicationapp authorized, tokens revoked, app permission changes

Querying the audit log via API

# Last 100 repo deletions $ gh api "orgs/acme/audit-log?phrase=action:repo.destroy&per_page=100" \ --jq '.[] | {actor, repo: .repo, at: .created_at}' # All branch protection bypass events this month $ gh api "orgs/acme/audit-log?phrase=action:protected_branch.policy_override&per_page=100" \ --jq '.[] | {actor, repo: .repo, branch: .data.ref, at: .created_at}' # All secret scanning push protection bypasses $ gh api "orgs/acme/audit-log?phrase=action:secret_scanning_push_protection.bypass" \ --paginate --jq '.[] | {actor, repo: .repo, reason: .data.bypass_reason}'

Audit log streaming (Enterprise)

For continuous compliance, stream audit events to your SIEM (Splunk, Datadog, Azure Monitor, AWS S3/EventBridge) in near real-time:

# Org Settings → Logs → Audit log → Log streaming → New stream # Supported destinations: # AWS S3, Azure Event Hubs, Google Cloud Storage # Splunk, Datadog, Elastic (via HTTP endpoint) # Configure via REST API: curl -X POST \ -H "Authorization: Bearer $GH_TOKEN" \ https://api.github.com/orgs/acme/audit-log/stream \ -d '{ "destination": "splunk", "config": { "url": "https://splunk.acme.com:8088/services/collector", "token": "'"$SPLUNK_HEC_TOKEN"'" }, "events": ["web", "git", "api"] }'
ALERT ON THESE
Set up SIEM alerts for: (1) any repo.destroy event, (2) org.remove_member events not triggered by your normal offboarding workflow, (3) secret_scanning_push_protection.bypass, (4) protected_branch.policy_override, (5) admin role grants (org.update_member where role becomes admin). These five event types cover the most common unauthorized or accidental destructive actions.
9.9

Billing & Usage Insights: Seats, Actions Minutes & Cost Governance

GitHub costs have three components: seats (user licences), Actions compute minutes, and storage (Packages + LFS + artifact retention). At scale, unmanaged Actions usage can generate surprising bills.

What drives Actions costs

ComponentRate (approx)Biggest cost drivers
Ubuntu runner minutes$0.008/minLong-running test suites, missing cache config, unnecessary workflows on every branch
Windows runner minutes$0.016/min (2×)Using Windows runners for tasks that could run on Linux
macOS runner minutes$0.08/min (10×)Running all tests on macOS when only UI tests need it
Storage$0.008 GB/day90-day artifact retention, large LFS files, old package versions

Identifying cost spikes

# Usage breakdown by repo via API (requires org billing role) $ gh api orgs/acme/settings/billing/actions \ --jq '{ included_minutes: .included_minutes, total_minutes_used: .total_minutes_used, paid_minutes_used: .total_paid_minutes_used }' # Per-repo breakdown (GitHub billing UI → Usage this month → Download CSV) # Then pivot by repo slug and runner OS to find the expensive workflows # Set spending limit to cap runaway bills $ gh api orgs/acme/settings/billing/actions/spending_limit \ --method PATCH --field selected_payment_method=free \ --field amount=500 # $500 hard cap per month

Cost reduction checklist

  • Fix missing caches first — dependency installs without cache hits are the #1 cause of wasted minutes. A 3-minute npm install that should be 20 seconds from cache, running 50 times a day, costs ~6 hrs of compute daily.
  • Use paths filters — don't run CI on docs-only changes.
  • Reduce macOS usage — only run macOS for tests that actually require macOS (iOS builds, macOS UI tests). Everything else on Linux.
  • Lower artifact retention — set retention-days: 7 on transient build artifacts. Default 90-day retention accumulates storage cost fast.
  • Cancel redundant runs — add concurrency groups with cancel-in-progress: true to all PR workflows.
  • Set timeout-minutes — a hung job costs money until GitHub's 6-hour timeout kills it.
9.10

Repository Lifecycle: Transfer, Archiving & Deletion Policy

At org scale, repositories without clear lifecycle governance accumulate as zombie repos — no owner, stale CI, out-of-date dependencies, potential security exposure. Establish explicit policies for each lifecycle stage.

Transfer

Transferring a repo moves it to a different owner (another org or user) while preserving issues, PRs, stars, and git history. GitHub sets up redirects from the old URL. Considerations:

  • CI/CD pipelines referencing the old repo name will break — update them before transferring
  • Webhooks, GitHub Apps, and Actions secrets are not transferred — must be reconfigured in the new location
  • If transferring to an org the destination org must have capacity for the new repo (size, LFS quota)
# Transfer a repo to another org $ gh api repos/acme/old-service/transfer \ --method POST \ --field new_owner=acme-archive \ --field new_name=old-service-archived

Archiving

Archived repos are read-only. Issues and PRs cannot be created; commits cannot be pushed; branch protection rules still apply but nothing can trigger them. The repo remains searchable and cloneable.

Archive when: a service is decommissioned, a library is superseded, an experiment ended. Do not archive as a substitute for documentation — add a README note explaining the repo's status and what replaced it before archiving.

$ gh repo archive acme/old-service --yes # Batch archive repos inactive for 2+ years $ gh repo list acme --limit 1000 --json name,pushedAt \ --jq '.[] | select(.pushedAt < "2023-01-01") | .name' \ | xargs -I{} gh repo archive acme/{} --yes

Deletion policy

Deletion is permanent (beyond GitHub's 90-day recovery window for Enterprise). Org policy should require:

  1. Export of issues/PRs/wiki (if historically valuable) — use gh issue list --json or the export API
  2. Confirmation that no other service depends on the repo's packages or releases
  3. A 30-day archive period before deletion (gives dependent teams time to notice)
  4. Deletion logged and attributed to a named individual in the offboarding runbook
RECOVER A DELETED REPO
GitHub Enterprise orgs have a 90-day window to restore a deleted repo via the Admin panel (enterprise.github.com → Deleted repositories). After 90 days, deletion is irreversible. For GitHub Teams, contact GitHub support within 90 days — restoration is possible but not guaranteed. This is why archiving before deletion is the right policy: archive first, then delete after a waiting period.

Up Next — Phase 10: Releases & GitHub Packages

Semantic versioning, annotated and signed tags, GitHub Releases with auto-generated notes, automating releases with release-please, changelogs with git-cliff, GitHub Packages registry for npm/Maven/Docker, and package retention policies.

Continue to Phase 10 → Back to Hub