---
name: permission_audits
description: Audit who can touch what: inventory API keys, OAuth grants, repo access, and shared links, then flag stale, over-scoped, or ownerless access before it becomes an incident. Use when reviewing access hygiene, after team changes, or when someone asks "who still has access to this".
---

# Permission Audits

## Purpose

Access rots. Keys outlive the people who made them, contractors keep repo access after the contract ends, and "anyone with the link" shares spread quietly. This skill runs a periodic access audit: inventory everything that grants access, score each grant against simple rules, and turn the findings into a fix list ordered by risk. It is about the human side of access, not IAM policy syntax.

**Not this skill:** `dependency-audits` covers auditing your software packages. This skill covers auditing the people, apps, and keys that can reach your stuff.

## Workflow

### 1. Pick the surface for this pass

Do not boil the ocean. One audit pass covers one surface: GitHub org repos, API keys in the secret store, OAuth grants on a Google or GitHub account, shared docs and drive folders, or SaaS admin panels. A pass should take under an hour.

### 2. Build the inventory

List every grant on that surface with five fields: name, type (api-key, oauth-app, repo-collaborator, service-account, doc-share), owner, scope (read, write, admin), created date, and last-used date. Pull it from the real admin surface: GitHub settings pages, the cloud console IAM view, the OAuth "third-party access" page. Do not reconstruct it from memory.

### 3. Run the rule check

Feed the inventory into the checker below. It flags five patterns, ordered by severity:

- Unused for 180+ days: revoke it. Access nobody uses is pure risk.
- Admin scope with no two-factor auth on the owner account: the single most dangerous common finding.
- API key older than a year: rotate it. Old keys leak through backups, screenshots, and logs.
- Write or admin access idle 90+ days: downgrade to read or remove.
- Broad doc shares ("anyone with the link" with write): narrow to named people.
- Grants with no owner listed: assign one. Orphaned access never gets revoked because nobody feels responsible.

### 4. Fix in severity order, this session

Work the HIGH list before leaving the audit. Revoke the unused keys, enable 2FA on the admin accounts, narrow the broad shares. MEDIUM findings become a dated to-do list with names attached. LOW findings go into the next pass.

### 5. Write the log

One page: surface audited, date, counts by severity, what got fixed, what is still open and who owns it. File it where the team can find it. The log is what makes the next audit take twenty minutes instead of two hours.

### Access grant checker

Save the snippet below as `access_audit.py` and run it with `python3 access_audit.py inventory.json`. It prints findings ordered HIGH to LOW and exits 1 if any HIGH findings exist, so it fits in CI or a scheduled check.

```python
# access_audit.py
# Usage: python3 access_audit.py inventory.json
# inventory.json: {"grants": [{"name": "...", "type": "api-key|oauth-app|repo|service-account|doc-share",
#   "owner": "...", "scope": "read|write|admin", "created": "YYYY-MM-DD", "two_fa": true,
#   "last_used": "YYYY-MM-DD"}]}
# Prints findings with severity. Exit code 1 if any HIGH findings.
import json, sys
from datetime import date, timedelta

SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}

def days_since(d):
    return (date.today() - date.fromisoformat(d)).days

def audit(inventory):
    findings = []
    for g in inventory.get("grants", []):
        name = g.get("name", "?")
        age = days_since(g["created"])
        idle = days_since(g.get("last_used", g["created"]))
        if idle > 180:
            findings.append(("HIGH", f"{name}: unused for {idle} days, revoke it"))
        if g.get("scope") == "admin" and not g.get("two_fa"):
            findings.append(("HIGH", f"{name}: admin scope with no 2FA on the owner account"))
        if g.get("type") == "api-key" and age > 365:
            findings.append(("MEDIUM", f"{name}: key is {age} days old, rotate it"))
        if g.get("type") == "doc-share" and g.get("scope") == "write":
            findings.append(("MEDIUM", f"{name}: 'anyone with link' write share, narrow to named people"))
        if idle > 90 and g.get("scope") in ("write", "admin"):
            findings.append(("MEDIUM", f"{name}: write/admin access idle {idle} days, downgrade or remove"))
        if not g.get("owner"):
            findings.append(("LOW", f"{name}: no owner listed, assign one"))
    findings.sort(key=lambda f: SEVERITY_ORDER[f[0]])
    return findings

if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "inventory.json"
    findings = audit(json.load(open(path)))
    for sev, msg in findings:
        print(f"[{sev}] {msg}")
    if not findings:
        print("No findings.")
    sys.exit(1 if any(s == "HIGH" for s, _ in findings) else 0)
```

## Output Contract

Every permission audit delivered by this skill must include:

- The surface audited and the date.
- Finding counts by severity (HIGH / MEDIUM / LOW).
- Each finding in one line: the grant, the rule it broke, and the fix.
- What got fixed during the pass, and what is still open with an owner and a date.
- The log, filed where the next audit will find it.

## Operating Rules

- Inventory from the real admin surface, not memory. A grant you forgot exists is the one that bites you.
- Fix every HIGH finding in the same session. An audit that produces a to-do list with no revoked keys changed nothing.
- One surface per pass. A pass that tries to cover everything covers nothing well.
- Every grant gets an owner and a last-used date. If the surface cannot tell you when a grant was last used, treat it as idle.
- Re-audit on a schedule: quarterly for stable surfaces, after every team change for the surfaces that person touched.
- Never share the audit log publicly or paste real key values into it. Grant names and scopes, never secrets.
