Skip to content

Grant or revoke an app's access to specific SharePoint sites (Sites.Selected).

The least-privilege companion to getting-started/: point it at a service principal and a list of sites to grant per-site access, or pass --revoke to remove it again. The plain Sites.Selected model keeps an app scoped to exactly the sites you list, instead of tenant-wide Sites.FullControl.All.

# Grant write access to two sites
python grant_app_site_access.py --app-id <client-id>         --site https://contoso.sharepoint.com/sites/alpha         --site https://contoso.sharepoint.com/sites/beta

# Revoke it again, reading the list from a file
python grant_app_site_access.py --app-id <client-id> --sites-file sites.txt --revoke

Permissions

Requires the calling app to hold Sites.FullControl.All (application) with admin consent; the target app must already have Sites.Selected granted.

Reference

View source

from __future__ import annotations

import argparse
from pathlib import Path

from office365.graph_client import GraphClient
from tests.settings import cert_path, cert_thumbprint, client_id, tenant


def read_sites(args: argparse.Namespace) -> list[str]:
    """Collect site URLs from ``--site`` flags and an optional ``--sites-file``."""
    sites = list(args.site)
    if args.sites_file:
        for raw in Path(args.sites_file).expanduser().read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if line and not line.startswith("#"):
                sites.append(line)
    return list(dict.fromkeys(sites))  # de-duplicate, keep order


def main() -> None:
    parser = argparse.ArgumentParser(description="Grant or revoke an app's access to specific SharePoint sites")
    parser.add_argument("--app-id", required=True, help="application (client) ID of the target app")
    parser.add_argument("--site", action="append", default=[], metavar="URL", help="site URL (repeatable)")
    parser.add_argument("--sites-file", help="file with one site URL per line (# comments allowed)")
    parser.add_argument("--role", default="write", choices=["read", "write", "owner"], help="role to grant")
    parser.add_argument("--revoke", action="store_true", help="revoke access instead of granting it")
    parser.add_argument("--dry-run", action="store_true", help="show the plan without changing anything")
    parser.add_argument("--private-key", default=cert_path, help="PEM private key of the calling app")
    args = parser.parse_args()

    sites = read_sites(args)
    if not sites:
        parser.error("provide at least one --site or a --sites-file")

    action = "Revoke access" if args.revoke else f"Grant '{args.role}'"
    print(f"{action} on {len(sites)} site(s) for app {args.app_id}:")
    for url in sites:
        print(f"  {url}")
    if args.dry_run:
        print("\nDry run: nothing changed.")
        return

    private_key = Path(args.private_key).expanduser().read_text(encoding="utf-8")
    client = GraphClient(tenant=tenant).with_certificate(client_id, cert_thumbprint, private_key)

    for url in sites:
        site = client.sites.get_by_url(url).execute_query()
        if args.revoke:
            site.revoke_app_access(args.app_id).execute_query()
            print(f"  revoked  {url}")
        else:
            site.grant_app_access(args.app_id, args.role).execute_query()
            print(f"  granted  {url}")

    print(f"\nDone: {len(sites)} site(s).")


if __name__ == "__main__":
    main()

← Back to Recipes