mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
Merge pull request #230 from superdesigndev/feat/google-tag-man-v1
feat(oauth): add Google Tag Manager support
This commit is contained in:
@@ -31,6 +31,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `render.yaml` | ops/deploy.md |
|
||||
| `scripts/build_plugin.py` | interface/skill.md |
|
||||
| `scripts/catalog_drift.py` | architecture/catalog.md |
|
||||
| `scripts/catalog_ingest.py` | architecture/catalog.md |
|
||||
| `scripts/catalog_validate.py` | architecture/catalog.md |
|
||||
| `scripts/dump_surface.py` | architecture/composition.md |
|
||||
| `scripts/minimax_plugin.py` | interface/skill.md |
|
||||
@@ -92,6 +93,8 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/catalog/fx.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/google-search-console.extended.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/google-search-console.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/google-tag-manager.extended.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/google-tag-manager.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/justoneapi.extended.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog/tikhub.extended.yaml` | architecture/catalog.md |
|
||||
| `src/treg/catalog_store.py` | architecture/catalog.md, interface/api.md, interface/catalog-review-proposal.md |
|
||||
@@ -179,7 +182,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
|---|---|
|
||||
| `architecture/ads-conversions.md` | `adsconv.py`, `signup.py`, `adtrack.js` |
|
||||
| `architecture/auth-secrets.md` | `injectors.py`, `crypto.py`, `oauth.py`, `oauth_providers.py`, `health.py`, `connect.py`, `connections.py`, `resources.py` |
|
||||
| `architecture/catalog.md` | `catalog-drift.yml`, `catalog_drift.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `aviato.yaml`, `crustdata.yaml`, `aviato.companies.acquisitions.json`, `aviato.companies.employees.json`, `aviato.companies.enrich.bulk.json`, `aviato.companies.enrich.json`, `aviato.companies.founders.json`, `aviato.companies.funding_rounds.json`, `aviato.companies.investments.json`, `aviato.companies.outbound_investments.json`, `aviato.companies.search.json`, `aviato.linkedin.company.posts.json`, `aviato.linkedin.post.comments.json`, `aviato.linkedin.post.reactions.json`, `aviato.linkedin.post.reposts.json`, `aviato.linkedin.user.posts.json`, `aviato.people.contact.get.json`, `aviato.people.email.find.json`, `aviato.people.enrich.bulk.json`, `aviato.people.enrich.json`, `aviato.people.phone.find.json`, `aviato.people.search.json`, `aviato.people.search.simple.json`, `crustdata.companies.autocomplete.json`, `crustdata.companies.enrich.json`, `crustdata.companies.identify.json`, `crustdata.companies.jobs.search.json`, `crustdata.companies.search.json`, `crustdata.people.autocomplete.json`, `crustdata.people.enrich.json`, `crustdata.people.search.json`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `justoneapi.extended.yaml`, `tikhub.extended.yaml`, `catalog_store.py`, `endpoint_stats.py`, `catalog.py` |
|
||||
| `architecture/catalog.md` | `catalog-drift.yml`, `catalog_drift.py`, `catalog_ingest.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `aviato.yaml`, `crustdata.yaml`, `aviato.companies.acquisitions.json`, `aviato.companies.employees.json`, `aviato.companies.enrich.bulk.json`, `aviato.companies.enrich.json`, `aviato.companies.founders.json`, `aviato.companies.funding_rounds.json`, `aviato.companies.investments.json`, `aviato.companies.outbound_investments.json`, `aviato.companies.search.json`, `aviato.linkedin.company.posts.json`, `aviato.linkedin.post.comments.json`, `aviato.linkedin.post.reactions.json`, `aviato.linkedin.post.reposts.json`, `aviato.linkedin.user.posts.json`, `aviato.people.contact.get.json`, `aviato.people.email.find.json`, `aviato.people.enrich.bulk.json`, `aviato.people.enrich.json`, `aviato.people.phone.find.json`, `aviato.people.search.json`, `aviato.people.search.simple.json`, `crustdata.companies.autocomplete.json`, `crustdata.companies.enrich.json`, `crustdata.companies.identify.json`, `crustdata.companies.jobs.search.json`, `crustdata.companies.search.json`, `crustdata.people.autocomplete.json`, `crustdata.people.enrich.json`, `crustdata.people.search.json`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `google-tag-manager.yaml`, `google-tag-manager.extended.yaml`, `justoneapi.extended.yaml`, `tikhub.extended.yaml`, `catalog_store.py`, `endpoint_stats.py`, `catalog.py` |
|
||||
| `architecture/composition.md` | `bootstrap.py`, `bootstrap_http.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py` |
|
||||
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `sitetrack.js`, `models.py`, `timeutil.py`, `db.py`, `referrals.py`, `referrals.py`, `audit.py`, `analytics.py`, `ratestore.py`, `auth.py` |
|
||||
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `__init__.py`, `teams.py`, `__init__.py`, `test_import_lightness.py` |
|
||||
|
||||
@@ -18,7 +18,7 @@ covers (frontmatter `sources:`). Regenerate this index with
|
||||
|---|---|---|
|
||||
| [Google Ads conversion tracking — capture, outbox, upload](architecture/ads-conversions.md) | shipped | adsconv.py, signup.py, adtrack.js |
|
||||
| [Auth & secrets — injectors, encryption, OAuth freshness, health](architecture/auth-secrets.md) | shipped | injectors.py, crypto.py, oauth.py, oauth_providers.py, … |
|
||||
| [Endpoint catalog — what you can DO with a connected key, and which provider should do it](architecture/catalog.md) | shipped | catalog-drift.yml, catalog_drift.py, catalog_validate.py, aliases.yaml, … |
|
||||
| [Endpoint catalog — what you can DO with a connected key, and which provider should do it](architecture/catalog.md) | shipped | catalog-drift.yml, catalog_drift.py, catalog_ingest.py, catalog_validate.py, … |
|
||||
| [Application composition and deployment roles](architecture/composition.md) | shipped | bootstrap.py, bootstrap_http.py, connect.py, mcp_oauth.py, … |
|
||||
| [Data model — the registry tables, async DB, audit writer](architecture/data-model.md) | shipped | alembic.ini, env.py, 0001_baseline_current_schema.py, sitetrack.js, … |
|
||||
| [Enforced import boundaries](architecture/import-boundaries.md) | shipped | pyproject.toml, ci.yml, __init__.py, __init__.py, … |
|
||||
|
||||
@@ -90,7 +90,7 @@ unrenewable one earns a warning (`EXPIRING_SOON_DAYS=7`). `connection_view()` is
|
||||
## Curated OAuth provider registry (`oauth_providers.py`)
|
||||
Two ways to connect a provider. **Bring-your-own (BYO):** `POST /oauth/start` takes a caller-supplied
|
||||
`client_id`/`client_secret`/URIs — works for any OAuth2 provider. **Curated:** for the providers where
|
||||
**treg itself holds the approved app** (Google Search Console/Analytics/Business Profile/Ads, YouTube,
|
||||
**treg itself holds the approved app** (Google Search Console/Analytics/Business Profile/Tag Manager/Ads, YouTube,
|
||||
LinkedIn, X, TikTok, Facebook, Instagram, Meta Ads — added PRs #20/#21), the user picks a provider and
|
||||
consents, supplying nothing. The asymmetry is the point of a hosted registry: the gating cost on these
|
||||
platforms is the *approval* (a Google Ads developer token, Meta App Review), not the OAuth dance — treg
|
||||
@@ -104,6 +104,15 @@ Google Search Console's hand-written tool example calls out its distinct direct-
|
||||
substitute `{site_url}` with a value encoded exactly once (`sc-domain%3Aexample.com`), and never encode
|
||||
again a property identifier returned by the sites list.
|
||||
|
||||
Google Tag Manager shares the standard Google client credentials and exposes three cumulative tiers:
|
||||
`read` grants `tagmanager.readonly`; `write` adds `tagmanager.edit.containers`; `manage` adds
|
||||
`tagmanager.edit.containerversions` and `tagmanager.publish`. The account list is both the health probe
|
||||
and resource picker, with the selected `accounts/{id}` path stamped into the tool example. treg
|
||||
deliberately does **not** request `tagmanager.delete.containers`, `tagmanager.manage.users`, or
|
||||
`tagmanager.manage.accounts`: agents can audit configuration, prepare workspace changes, create
|
||||
versions, and publish (including rollback by publishing an earlier version), but cannot delete whole
|
||||
containers or administer access.
|
||||
|
||||
Each entry is a frozen `OAuthProvider` dataclass; `REGISTRY` is the `{service: provider}` map. Key
|
||||
module symbols:
|
||||
- `get(service)` — look up one provider. `credentials(provider)` — treg's own id/secret (raises if this
|
||||
|
||||
@@ -4,6 +4,7 @@ status: shipped
|
||||
sources:
|
||||
- .github/workflows/catalog-drift.yml
|
||||
- scripts/catalog_drift.py
|
||||
- scripts/catalog_ingest.py
|
||||
- scripts/catalog_validate.py
|
||||
- src/treg/catalog/aliases.yaml
|
||||
- src/treg/catalog/fx.yaml
|
||||
@@ -40,6 +41,8 @@ sources:
|
||||
- src/treg/catalog/examples/crustdata.people.search.json
|
||||
- src/treg/catalog/google-search-console.yaml
|
||||
- src/treg/catalog/google-search-console.extended.yaml
|
||||
- src/treg/catalog/google-tag-manager.yaml
|
||||
- src/treg/catalog/google-tag-manager.extended.yaml
|
||||
- src/treg/catalog/justoneapi.extended.yaml
|
||||
- src/treg/catalog/tikhub.extended.yaml
|
||||
- src/treg/catalog_store.py
|
||||
@@ -120,6 +123,11 @@ Path placeholders are substituted by the marketplace caller. Raw values are perc
|
||||
that already contains a valid `%HH` escape is kept verbatim so callers can safely reuse encoded resource
|
||||
names returned by an upstream API. An invalid/literal `%` is still encoded as `%25`. Search Console's
|
||||
`siteUrl` examples deliberately use the raw `sc-domain:example.com` form to demonstrate the default path.
|
||||
Google Tag Manager is the opposite case: its `parent`/`path` values describe a hierarchy rather than
|
||||
one opaque identifier, so the curated catalog exposes atomic account/container/workspace/version ids.
|
||||
`catalog_ingest.google_flat_path_params` makes the generated GTM input schema use the same atomic
|
||||
placeholders already present in Discovery's `flatPath`; no slash-delimited resource name is passed
|
||||
through one placeholder and accidentally encoded as `%2F`.
|
||||
|
||||
## Where things live
|
||||
|
||||
@@ -696,7 +704,7 @@ can say "bring your own key" *before* the call instead of relaying the 403 after
|
||||
calls it `douyin`; if both don't land on `douyin`, the marketplace shelf splits in two and the
|
||||
cross-provider comparison the catalog exists for silently stops working.
|
||||
|
||||
### The first-party OAuth wave (2026-07-28)
|
||||
### The first-party OAuth wave (2026-07-28; Google Tag Manager added 2026-08-27)
|
||||
|
||||
The scraper providers sell breadth and their extended tier reads as a menu. The nine providers
|
||||
where treg owns the OAuth app are the opposite question — *what can this one connected account
|
||||
@@ -706,6 +714,7 @@ actually do?* — and their sources differ per provider:
|
||||
|---|---|---|---|
|
||||
| google-search-console | searchconsole v1 discovery | 7 | 0 |
|
||||
| google-analytics | analyticsdata + analyticsadmin v1beta discovery | 63 (55 on the admin host) | 32 |
|
||||
| google-tag-manager | tagmanager v2 discovery | 98 | 8 |
|
||||
| google-business-profile | six My Business discovery docs + 7 hand-listed legacy v4 routes | 60 (45 off-host) | n/a |
|
||||
| youtube | youtube v3 discovery + the published quota-cost table | 76 | 2 |
|
||||
| google-ads | the GAQL resource reference — one entry per queryable resource | 42 | 0 |
|
||||
@@ -721,6 +730,10 @@ Three things generalise from it:
|
||||
HTML reference. Scopes are ALTERNATIVES (holding any one suffices), so coverage is an
|
||||
intersection, not a subset. The My Business documents are the exception that declares no scopes
|
||||
at all, which is why that provider has no computable gaps.
|
||||
- **Google Tag Manager keeps risky administration outside the grant.** Its core catalog presents an
|
||||
audit → workspace edit → version/publish workflow across cumulative `read`/`write`/`manage` tiers.
|
||||
The generated catalog still lists methods requiring container deletion or account/user management,
|
||||
but marks all eight with `scope_gap`; those three scopes are intentionally never requested.
|
||||
- **Google Ads is a resource list, not a route list.** One endpoint (`googleAds:searchStream`)
|
||||
answers every read and what varies is the GAQL `FROM` clause, so the unit of coverage is the
|
||||
queryable resource. Forty entries share a path and differ in `input.note` and `docs_url`.
|
||||
|
||||
@@ -334,7 +334,7 @@ Server side (`domain.identity.access`): `require_identity` (who, from token OR s
|
||||
|
||||
## Marketplace — the in-browser OAuth-connect UI (`view==='connections'` / `'provider'`)
|
||||
The dashboard now runs the whole **hosted connect flow** in the browser, so a member can attach a
|
||||
provider account (Google Analytics, Search Console, Google Ads, Slack, Meta/Facebook/Instagram, X,
|
||||
provider account (Google Analytics, Search Console, Google Tag Manager, Google Ads, Slack, Meta/Facebook/Instagram, X,
|
||||
TikTok, LinkedIn, YouTube, …) without touching the CLI. `loadConnections` fetches **`GET /oauth/providers`**
|
||||
(server route `oauth_providers_list` → `oauth_providers.listing()`, each row carrying `service`,
|
||||
`display_name`, `category`, `summary`, `capabilities`, `scope_detail`, `auth_kind`, `supports_discovery`,
|
||||
@@ -362,6 +362,11 @@ still opens the provider page (`openProvider(service)`); each row has `id="prov-
|
||||
Rows show the **provider logo** served by convention from
|
||||
**`/logos/<service>.svg`** (`.plogo-tile`/`.plogo`, `@error` hides a missing file) — the `StaticFiles`
|
||||
mount `_LOGO_DIR` (`src/treg/web/logos/`). `connCount` labels how many accounts are already connected.
|
||||
Google Tag Manager follows that same generic UI: its capability picker offers cumulative
|
||||
read/write/manage access, account discovery labels each `accounts/{id}` resource by name, and the
|
||||
selected account stamps a runnable containers-list path into the provisioned tool. Its provider and
|
||||
platform logo assets both carry the Google Tag Manager mark, so the catalog tile, platform header,
|
||||
provider page, and expanded endpoint rows resolve to the same identity.
|
||||
The tab bar itself is `v-if`'d on `plats.list.length` and `mkTabActive` collapses to `'platform'` when
|
||||
the catalog is absent, so a build that predates `/catalog` renders exactly the old marketplace.
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ def write_extended(provider: str, source: dict, endpoints: list[dict], notes: li
|
||||
"# `capability` mappings (with their platform correction) are added later and carried across",
|
||||
f"# re-ingests by id via carry_verification. Routes curated in core {provider}.yaml are excluded here.",
|
||||
]
|
||||
header += [f"# {n}" for n in notes]
|
||||
header += [f"# {n}" if n else "#" for n in notes]
|
||||
body = yaml.safe_dump(
|
||||
{"provider": provider, "source": source, "endpoints": endpoints},
|
||||
sort_keys=False,
|
||||
@@ -1125,7 +1125,44 @@ def google_entry(
|
||||
return entry
|
||||
|
||||
|
||||
FREE_QUOTA = {"type": "free", "value": 0.0, "currency": "USD",
|
||||
def google_flat_path_params(entry: dict) -> dict:
|
||||
"""Align Discovery parameters with the atomic placeholders in Google's ``flatPath``.
|
||||
|
||||
Discovery describes hierarchical routes semantically as one ``parent``/``path``/``name``
|
||||
resource, while ``flatPath`` expands that resource into placeholders such as ``accountsId`` and
|
||||
``containersId``. treg intentionally percent-encodes each catalog placeholder as one path
|
||||
segment, so advertising the semantic resource name would both generate an unusable command and
|
||||
encode its hierarchy as ``%2F``. Keep the flattened route and expose its atomic ids instead.
|
||||
|
||||
This helper is initially applied only to GTM. Analytics and Business Profile have older
|
||||
generated metadata with the same mismatch; repairing and regenerating those catalogs belongs in
|
||||
a separate change rather than silently broadening the GTM provider diff.
|
||||
"""
|
||||
names = list(dict.fromkeys(re.findall(r"{([A-Za-z0-9_]+)}", str(entry.get("path") or ""))))
|
||||
if not names:
|
||||
return entry
|
||||
inp = entry.get("input")
|
||||
if not isinstance(inp, dict):
|
||||
inp = {}
|
||||
entry["input"] = inp
|
||||
current = inp.get("pathParams")
|
||||
if isinstance(current, dict) and set(current) == set(names):
|
||||
return entry
|
||||
inp["pathParams"] = {
|
||||
name: {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"note": (
|
||||
f"Atomic {name} path segment from Google's expanded resource name; "
|
||||
"do not pass a slash-delimited parent/path value"
|
||||
),
|
||||
}
|
||||
for name in names
|
||||
}
|
||||
return entry
|
||||
|
||||
|
||||
FREE_QUOTA = {"type": "free", "value": 0.0, "currency": "USD", "unit": "call",
|
||||
"note": "no per-call charge; billed against the API's daily quota"}
|
||||
|
||||
|
||||
@@ -1217,6 +1254,49 @@ def ingest_google_analytics(refresh: bool) -> tuple[Path, dict]:
|
||||
), endpoints, notes), {"scope_gaps": gaps, "admin": admin}
|
||||
|
||||
|
||||
# --- google-tag-manager ------------------------------------------------------------------------
|
||||
|
||||
def ingest_google_tag_manager(refresh: bool) -> tuple[Path, dict]:
|
||||
provider = "google-tag-manager"
|
||||
granted = {
|
||||
"https://www.googleapis.com/auth/tagmanager.readonly",
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containers",
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containerversions",
|
||||
"https://www.googleapis.com/auth/tagmanager.publish",
|
||||
}
|
||||
skip = core_route_keys(provider)
|
||||
endpoints, gaps = [], 0
|
||||
for m in google_methods(google_discovery("tagmanager", "v2", refresh)):
|
||||
e = google_entry(
|
||||
provider,
|
||||
m,
|
||||
platform="google-tag-manager",
|
||||
granted=granted,
|
||||
cost=FREE_QUOTA,
|
||||
docs_url="https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2",
|
||||
)
|
||||
google_flat_path_params(e)
|
||||
if _route_key(e["method"], e["path"]) in skip:
|
||||
continue
|
||||
gaps += bool(e.get("scope_gap"))
|
||||
endpoints.append(e)
|
||||
notes = [
|
||||
"",
|
||||
"ONE HOST. Every path is relative to https://tagmanager.googleapis.com, the OAuth",
|
||||
"provider's base_url. treg's cumulative read/write/manage capabilities grant readonly,",
|
||||
"edit.containers, edit.containerversions, and publish respectively.",
|
||||
"",
|
||||
"Methods that require tagmanager.delete.containers, tagmanager.manage.users, or",
|
||||
"tagmanager.manage.accounts remain listed with `scope_gap:` for discoverability, but are",
|
||||
"intentionally unavailable: treg does not request destructive container deletion or account",
|
||||
"administration access.",
|
||||
]
|
||||
return write_extended(provider, _oauth_source(
|
||||
"google discovery document",
|
||||
["https://tagmanager.googleapis.com/$discovery/rest?version=v2"],
|
||||
), endpoints, notes), {"scope_gaps": gaps}
|
||||
|
||||
|
||||
# --- google-business-profile -------------------------------------------------------------------
|
||||
# Google retired the single "My Business API v4" into SIX narrow services, each on its own host.
|
||||
# base_url is the account-management one, so only that family is callable through the provisioned
|
||||
@@ -2000,6 +2080,7 @@ def ingest_meta(service: str, refresh: bool) -> tuple[Path, dict]:
|
||||
INGESTERS.update({
|
||||
"google-search-console": ingest_google_search_console,
|
||||
"google-analytics": ingest_google_analytics,
|
||||
"google-tag-manager": ingest_google_tag_manager,
|
||||
"google-business-profile": ingest_google_business_profile,
|
||||
"youtube": ingest_youtube,
|
||||
"google-ads": ingest_google_ads,
|
||||
|
||||
@@ -88,6 +88,7 @@ platforms:
|
||||
# --- Advertising: ad platforms & creator marketplaces ----------------------------------------
|
||||
google-ads: {label: "Google Ads", category: "Advertising", featured: 1, summary: "Query your own Google Ads accounts with GAQL, and manage campaign budgets."}
|
||||
meta-ads: {label: "Meta Ads (Facebook & Instagram)", category: "Advertising", featured: 2, summary: "Your Meta ad accounts, campaigns and insights across Facebook and Instagram."}
|
||||
google-tag-manager: {label: "Google Tag Manager", category: "Advertising", featured: 3, summary: "Audit and manage tags, triggers, variables, workspaces and published container versions in your own GTM account."}
|
||||
douyin-xingtu: {label: "Xingtu — Douyin's creator marketplace (influencer rates, audience data)", category: "Advertising", summary: "Influencer rates, audience breakdowns and campaign performance."}
|
||||
xiaohongshu-pugongying: {label: "Pugongying — Xiaohongshu's creator marketplace", category: "Advertising", summary: "Creator pricing, audience and note performance from Xiaohongshu's creator marketplace."}
|
||||
qq-huxuan: {label: "QQ Huxuan — Tencent's creator marketplace", category: "Advertising", summary: "Creator and placement data from Tencent's Huxuan marketplace."}
|
||||
@@ -175,6 +176,14 @@ capabilities:
|
||||
google-analytics.metadata: "List available dimensions & metrics"
|
||||
google-analytics.realtime: "Realtime visitors on your site"
|
||||
google-analytics.report: "Run a traffic or behaviour report"
|
||||
google-tag-manager.accounts: "List the Tag Manager accounts you can access"
|
||||
google-tag-manager.containers: "List containers in an account"
|
||||
google-tag-manager.live_version: "Inspect a container's live version"
|
||||
google-tag-manager.tags: "List tags in a workspace"
|
||||
google-tag-manager.version.create: "Create a version from a workspace"
|
||||
google-tag-manager.version.publish: "Publish a container version"
|
||||
google-tag-manager.workspace.sync: "Sync a workspace with the latest container version"
|
||||
google-tag-manager.workspaces: "List workspaces in a container"
|
||||
google-business.accounts: "List the accounts you manage"
|
||||
google-business.locations: "Your listings with hours & categories"
|
||||
google-business.review.reply: "Reply to a customer review"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
# unverified — endpoints require a connected OAuth account; verify via a treg connection later.
|
||||
#
|
||||
# The OAuth capabilities are deliberately cumulative:
|
||||
# read → tagmanager.readonly
|
||||
# write → read + tagmanager.edit.containers
|
||||
# manage → write + tagmanager.edit.containerversions + tagmanager.publish
|
||||
#
|
||||
# treg intentionally does not request tagmanager.delete.containers, tagmanager.manage.users, or
|
||||
# tagmanager.manage.accounts. A connected agent can inspect configuration, prepare changes in a
|
||||
# workspace, create versions, and publish or roll back by publishing an earlier version, but it
|
||||
# cannot delete whole containers or administer account/container access.
|
||||
provider: google-tag-manager
|
||||
source:
|
||||
docs: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2
|
||||
openapi: https://tagmanager.googleapis.com/$discovery/rest?version=v2
|
||||
curated: 2026-08-27
|
||||
|
||||
limits: "Google Tag Manager API project and per-user quotas apply"
|
||||
pricing_url: https://developers.google.com/tag-platform/tag-manager/api/v2/limits-quotas
|
||||
endpoints:
|
||||
- id: google-tag-manager.accounts
|
||||
capability: google-tag-manager.accounts
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: GET
|
||||
path: /tagmanager/v2/accounts
|
||||
name: "List your Tag Manager accounts"
|
||||
summary: "List the Google Tag Manager accounts you can access"
|
||||
input:
|
||||
queryParams:
|
||||
pageToken: {type: string, required: false}
|
||||
note: "Returns {account: [{path, accountId, name, shareData, fingerprint}]}; `path` (for example accounts/123456) is the resource id used by container calls. This is also the connection health probe and account picker"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; billed against the API's project and per-user quota"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts/list
|
||||
|
||||
- id: google-tag-manager.containers
|
||||
capability: google-tag-manager.containers
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: GET
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers
|
||||
name: "List containers in a Tag Manager account"
|
||||
summary: "List the web, server, Android and iOS containers in an account"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, note: "digits from the account resource path (accounts/123456)", example: "123456"}
|
||||
queryParams:
|
||||
pageToken: {type: string, required: false}
|
||||
note: "Use each container's `path` in the workspace and live-version calls"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers/list
|
||||
|
||||
- id: google-tag-manager.workspaces
|
||||
capability: google-tag-manager.workspaces
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: GET
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces
|
||||
name: "List workspaces in a container"
|
||||
summary: "List the draft workspaces available in a Tag Manager container"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, note: "digits from the account resource path", example: "123456"}
|
||||
container_id: {type: string, required: true, note: "digits from the container resource path", example: "789"}
|
||||
queryParams:
|
||||
pageToken: {type: string, required: false}
|
||||
note: "A workspace isolates draft tags, triggers and variables before a version is created"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/list
|
||||
|
||||
- id: google-tag-manager.tags
|
||||
capability: google-tag-manager.tags
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: GET
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}/tags
|
||||
name: "List tags in a workspace"
|
||||
summary: "Inspect the tags configured in a Tag Manager workspace"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, example: "123456"}
|
||||
container_id: {type: string, required: true, example: "789"}
|
||||
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
|
||||
queryParams:
|
||||
pageToken: {type: string, required: false}
|
||||
note: "Tag parameters are schema-driven and vary by tag type; read triggers and variables as related workspace resources before proposing changes"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces.tags/list
|
||||
|
||||
- id: google-tag-manager.live-version
|
||||
capability: google-tag-manager.live_version
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: GET
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/versions:live
|
||||
name: "Get a container's live version"
|
||||
summary: "Inspect the configuration currently published from a Tag Manager container"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, example: "123456"}
|
||||
container_id: {type: string, required: true, note: "digits from the container resource path", example: "789"}
|
||||
note: "Compare the returned live version with a workspace before creating or publishing a replacement"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.versions/live
|
||||
|
||||
- id: google-tag-manager.workspace-sync
|
||||
kind: action
|
||||
capability: google-tag-manager.workspace.sync
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: POST
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}:sync
|
||||
name: "Sync a workspace"
|
||||
summary: "Merge the latest container version into a draft workspace and report conflicts"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, example: "123456"}
|
||||
container_id: {type: string, required: true, example: "789"}
|
||||
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
|
||||
note: "This changes the draft workspace when upstream changes can be merged; inspect mergeConflict[] before continuing"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/sync
|
||||
|
||||
- id: google-tag-manager.version-create
|
||||
kind: action
|
||||
capability: google-tag-manager.version.create
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: POST
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/workspaces/{workspace_id}:create_version
|
||||
name: "Create a container version from a workspace"
|
||||
summary: "Snapshot a validated draft workspace as a new container version"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, example: "123456"}
|
||||
container_id: {type: string, required: true, example: "789"}
|
||||
workspace_id: {type: string, required: true, note: "digits from the workspace resource path", example: "1"}
|
||||
body:
|
||||
name: {type: string, required: false, example: "Add GA4 purchase event"}
|
||||
notes: {type: string, required: false, example: "Reviewed in workspace 1 before publishing"}
|
||||
bodyType: json
|
||||
note: "Requires the manage capability. Creating a version does not publish it; inspect compilerError and the returned containerVersion before publishing"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.workspaces/create_version
|
||||
|
||||
- id: google-tag-manager.version-publish
|
||||
kind: action
|
||||
capability: google-tag-manager.version.publish
|
||||
platform: google-tag-manager
|
||||
scope: own_account
|
||||
method: POST
|
||||
path: /tagmanager/v2/accounts/{account_id}/containers/{container_id}/versions/{container_version_id}:publish
|
||||
name: "Publish a container version"
|
||||
summary: "Make a selected Tag Manager container version live"
|
||||
input:
|
||||
pathParams:
|
||||
account_id: {type: string, required: true, example: "123456"}
|
||||
container_id: {type: string, required: true, example: "789"}
|
||||
container_version_id: {type: string, required: true, note: "digits from the container-version resource path", example: "4"}
|
||||
queryParams:
|
||||
fingerprint: {type: string, required: false, note: "optimistic-lock fingerprint returned with the version"}
|
||||
note: "Requires the manage capability and changes the live site/app configuration. Confirm the exact version and inspect compilerError before publishing. Rollback is performed by publishing an earlier version"
|
||||
cost: {type: free, value: 0, currency: USD, unit: call, note: "no per-call charge; platform quotas apply"}
|
||||
docs_url: https://developers.google.com/tag-platform/tag-manager/api/reference/rest/v2/accounts.containers.versions/publish
|
||||
@@ -322,8 +322,10 @@ class OAuthProvider:
|
||||
|
||||
|
||||
# ---- the registry ------------------------------------------------------------------------
|
||||
# One Google OAuth client covers Search Console, Analytics, Ads and Business Profile — but each is
|
||||
# registered separately so a connect only ever requests its own capability's scopes.
|
||||
# One shared Google OAuth client covers Search Console, Analytics, Business Profile, Tag Manager
|
||||
# and YouTube — but each is registered separately so a connect only ever requests its own
|
||||
# capability's scopes. Google Ads is the exception: its developer token is welded to a dedicated
|
||||
# Cloud project, so GOOGLE_ADS below names the separate google_ads_client_id pair.
|
||||
|
||||
GOOGLE_SEARCH_CONSOLE = OAuthProvider(
|
||||
service="google-search-console",
|
||||
@@ -452,6 +454,58 @@ GOOGLE_BUSINESS_PROFILE = OAuthProvider(
|
||||
discover_label_field="accountName",
|
||||
)
|
||||
|
||||
_GTM_READ = [
|
||||
"https://www.googleapis.com/auth/tagmanager.readonly",
|
||||
]
|
||||
_GTM_WRITE = [
|
||||
*_GTM_READ,
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containers",
|
||||
]
|
||||
|
||||
GOOGLE_TAG_MANAGER = OAuthProvider(
|
||||
service="google-tag-manager",
|
||||
display_name="Google Tag Manager",
|
||||
auth_uri="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
# Three honest tiers mirror GTM's release workflow. `write` can prepare changes in a workspace
|
||||
# but cannot turn them into a live release; `manage` adds version creation and publishing.
|
||||
# Deliberately absent: delete.containers, manage.users and manage.accounts. An agent that can
|
||||
# configure and publish tags does not also need authority to erase the whole container, change
|
||||
# who can access it, or administer the account.
|
||||
scopes={
|
||||
"read": _GTM_READ,
|
||||
"write": _GTM_WRITE,
|
||||
"manage": [
|
||||
*_GTM_WRITE,
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containerversions",
|
||||
"https://www.googleapis.com/auth/tagmanager.publish",
|
||||
],
|
||||
},
|
||||
client_id_setting="google_client_id",
|
||||
client_secret_setting="google_client_secret",
|
||||
category="Advertising",
|
||||
summary=(
|
||||
"Audit tags, triggers and variables, prepare changes in a workspace, and publish a reviewed container version."
|
||||
),
|
||||
base_url="https://tagmanager.googleapis.com",
|
||||
docs_url="https://developers.google.com/tag-platform/tag-manager/api/v2",
|
||||
examples=(
|
||||
{"method": "GET", "path": "tagmanager/v2/accounts",
|
||||
"note": "Every GTM account this Google user can access. Choose an account, then list its containers."},
|
||||
),
|
||||
resource_label="account",
|
||||
probe_path="/tagmanager/v2/accounts",
|
||||
discover_path="/tagmanager/v2/accounts",
|
||||
discover_key="account",
|
||||
discover_id_field="path",
|
||||
discover_label_field="name",
|
||||
resource_example={
|
||||
"method": "GET", "path": "tagmanager/v2/{resource}/containers",
|
||||
"note": "List the GTM containers in your selected account “{resource_name}”. Choose a container "
|
||||
"before listing its workspaces, tags, triggers or variables.",
|
||||
},
|
||||
)
|
||||
|
||||
GOOGLE_ADS = OAuthProvider(
|
||||
service="google-ads",
|
||||
display_name="Google Ads",
|
||||
@@ -2344,7 +2398,8 @@ PINTEREST_ADS = OAuthProvider(
|
||||
REGISTRY: dict[str, OAuthProvider] = {
|
||||
p.service: p
|
||||
for p in (
|
||||
GOOGLE_SEARCH_CONSOLE, GOOGLE_ANALYTICS, GOOGLE_BUSINESS_PROFILE, GOOGLE_ADS, YOUTUBE,
|
||||
GOOGLE_SEARCH_CONSOLE, GOOGLE_ANALYTICS, GOOGLE_BUSINESS_PROFILE, GOOGLE_TAG_MANAGER,
|
||||
GOOGLE_ADS, YOUTUBE,
|
||||
LINKEDIN, SLACK, X, TIKTOK, FACEBOOK, INSTAGRAM, META_ADS,
|
||||
# API-key providers
|
||||
APOLLO, PDL, AKTA, HUNTER, CRUNCHBASE, TIKHUB, BRIGHTDATA, SEMRUSH, JUSTONEAPI,
|
||||
@@ -2419,6 +2474,15 @@ SCOPE_LABELS: dict[str, str] = {
|
||||
"Manage your business listings, reviews and posts",
|
||||
"https://www.googleapis.com/auth/adwords":
|
||||
"Read campaigns, spend and performance, and manage campaigns",
|
||||
# Google — Tag Manager
|
||||
"https://www.googleapis.com/auth/tagmanager.readonly":
|
||||
"View your Tag Manager accounts, containers, workspaces and configuration",
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containers":
|
||||
"Create and change tags, triggers, variables and other workspace configuration",
|
||||
"https://www.googleapis.com/auth/tagmanager.edit.containerversions":
|
||||
"Create and manage Tag Manager container versions",
|
||||
"https://www.googleapis.com/auth/tagmanager.publish":
|
||||
"Publish Tag Manager container versions to your sites and apps",
|
||||
# Google — YouTube
|
||||
"https://www.googleapis.com/auth/youtube.readonly":
|
||||
"See your channel, videos and playlists",
|
||||
|
||||
@@ -6096,6 +6096,7 @@ createApp({
|
||||
'api.intercom.io':'me','api.stripe.com':'balance','api.render.com':'services?limit=1',
|
||||
'api.vercel.com':'v2/user','app.posthog.com':'api/projects/@current','eu.posthog.com':'api/projects/@current',
|
||||
'searchconsole.googleapis.com':'webmasters/v3/sites','googleads.googleapis.com':'v25/customers:listAccessibleCustomers',
|
||||
'tagmanager.googleapis.com':'tagmanager/v2/accounts',
|
||||
// part=snippet rather than the probe's part=id: same 1 quota unit, but it returns the channel
|
||||
// title, so the panel shows something a human recognises instead of an opaque UC… id.
|
||||
'youtube.googleapis.com':'youtube/v3/channels?part=snippet&mine=true',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Google Tag Manager">
|
||||
<polygon fill="#8AB4F8" points="150.261818 245.516364 105.825455 202.185455 201.258182 104.730909 247.265455 149.821818"/>
|
||||
<path fill="#4285F4" d="M150.450909 53.938182 106.174545 8.730909 9.36 104.629091c-12.48 12.48-12.48 32.712727 0 45.207273l95.36 95.985454 45.090909-42.181818-72.654545-76.407273 73.294545-73.294545Z"/>
|
||||
<path fill="#8AB4F8" d="m246.625455 105.370909-96-96c-12.494546-12.494545-32.756364-12.494545-45.25091 0-12.494545 12.494546-12.494545 32.756364 0 45.250909l96 96c12.494546 12.494546 32.756364 12.494546 45.25091 0 12.494545-12.494545 12.494545-32.756363 0-45.250909Z"/>
|
||||
<circle fill="#246FDB" cx="127.265455" cy="224.730909" r="31.272727"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Google Tag Manager">
|
||||
<polygon fill="#8AB4F8" points="150.261818 245.516364 105.825455 202.185455 201.258182 104.730909 247.265455 149.821818"/>
|
||||
<path fill="#4285F4" d="M150.450909 53.938182 106.174545 8.730909 9.36 104.629091c-12.48 12.48-12.48 32.712727 0 45.207273l95.36 95.985454 45.090909-42.181818-72.654545-76.407273 73.294545-73.294545Z"/>
|
||||
<path fill="#8AB4F8" d="m246.625455 105.370909-96-96c-12.494546-12.494545-32.756364-12.494545-45.25091 0-12.494545 12.494546-12.494545 32.756364 0 45.250909l96 96c12.494546 12.494546 32.756364 12.494546 45.25091 0 12.494545-12.494545 12.494545-32.756363 0-45.250909Z"/>
|
||||
<circle fill="#246FDB" cx="127.265455" cy="224.730909" r="31.272727"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
@@ -552,6 +553,32 @@ def test_call_templates_share_wire_encoding_and_quote_complete_query_arguments()
|
||||
assert "'phrase=two words'" in line
|
||||
|
||||
|
||||
def test_gtm_catalog_paths_and_declared_parameters_are_the_same_contract():
|
||||
"""Every GTM catalog command must ask for the atomic ids its path actually substitutes.
|
||||
|
||||
Google Discovery describes these as one semantic parent/path resource, but passing
|
||||
``accounts/…/containers/…`` through one treg placeholder encodes the hierarchy as ``%2F``.
|
||||
The curated and generated tiers therefore expose the flattened path segments instead.
|
||||
"""
|
||||
cat = cs.load()
|
||||
endpoints = [ep for ep in cat.by_id.values() if ep["provider"] == "google-tag-manager"]
|
||||
assert endpoints
|
||||
for ep in endpoints:
|
||||
placeholders = set(re.findall(r"{([A-Za-z0-9_]+)}", ep.get("path") or ""))
|
||||
declared = set((((ep.get("input") or {}).get("pathParams")) or {}))
|
||||
assert placeholders == declared, ep["id"]
|
||||
|
||||
core = [ep for ep in endpoints if ep["tier"] == "core"]
|
||||
for ep in core:
|
||||
for spec in (((ep.get("input") or {}).get("pathParams")) or {}).values():
|
||||
assert "/" not in str((spec or {}).get("example") or ""), ep["id"]
|
||||
|
||||
line = cs.call_template(cat.by_id["google-tag-manager.workspaces"])
|
||||
assert "--query account_id=123456" in line
|
||||
assert "--query container_id=789" in line
|
||||
assert "parent=" not in line
|
||||
|
||||
|
||||
def test_catalog_validator_rejects_an_unknown_endpoint_array_encoding(tmp_path, capsys):
|
||||
"""The endpoint encoding declaration is schema, not free-form prose. Exercise the real
|
||||
validator so deleting its validation block cannot leave a falsely green test suite."""
|
||||
@@ -911,6 +938,28 @@ def test_the_ingester_puts_a_POST_routes_arguments_in_the_BODY():
|
||||
assert "queryParams" in no_body and "body" not in no_body
|
||||
|
||||
|
||||
def test_gtm_ingestion_expands_semantic_resource_names_into_atomic_path_ids():
|
||||
"""The checked-in extended YAML must stay fixed after the next Discovery re-ingest."""
|
||||
import sys
|
||||
sys.path.insert(0, "scripts")
|
||||
from catalog_ingest import google_flat_path_params
|
||||
|
||||
entry = {
|
||||
"path": "/tagmanager/v2/accounts/{accountsId}/containers/{containersId}/workspaces",
|
||||
"input": {
|
||||
"pathParams": {
|
||||
"parent": {"type": "string", "required": True, "note": "container resource path"},
|
||||
},
|
||||
"queryParams": {"pageToken": {"type": "string", "required": False}},
|
||||
},
|
||||
}
|
||||
assert google_flat_path_params(entry) is entry
|
||||
params = entry["input"]["pathParams"]
|
||||
assert list(params) == ["accountsId", "containersId"]
|
||||
assert all(spec["required"] for spec in params.values())
|
||||
assert "pageToken" in entry["input"]["queryParams"]
|
||||
|
||||
|
||||
def test_a_published_spec_outranks_the_OPTIONS_probe():
|
||||
"""The probe infers a verb from a preflight; the spec is the provider's own contract. When the
|
||||
spec names exactly one method the spec wins, so a re-ingest inherits an upstream verb change
|
||||
|
||||
@@ -24,7 +24,7 @@ from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from httpx import AsyncClient
|
||||
|
||||
from treg import api as A, audit
|
||||
from treg import api as A, audit, catalog_store, oauth_providers
|
||||
from treg.config import get_settings
|
||||
from treg.db import session_maker
|
||||
from treg.models import Org
|
||||
@@ -177,6 +177,21 @@ def test_path_placeholders_fill_from_query_and_are_consumed():
|
||||
assert exc.value.status_code == 400 and "siteUrl" in exc.value.detail
|
||||
|
||||
|
||||
def test_gtm_catalog_builds_hierarchy_from_atomic_ids_without_encoded_slashes():
|
||||
ep = catalog_store.load().by_id["google-tag-manager.workspaces"]
|
||||
url, consumed = A._marketplace_upstream(
|
||||
ep,
|
||||
oauth_providers.GOOGLE_TAG_MANAGER,
|
||||
{"account_id": "123", "container_id": "456", "pageToken": "next"},
|
||||
)
|
||||
assert url == (
|
||||
"https://tagmanager.googleapis.com/tagmanager/v2/"
|
||||
"accounts/123/containers/456/workspaces"
|
||||
)
|
||||
assert "%2F" not in url
|
||||
assert consumed == {"account_id", "container_id"}
|
||||
|
||||
|
||||
async def test_deny_rules_cover_marketplace_calls(clients: AsyncClient):
|
||||
"""Policy is evaluated on the RESOLVED upstream — an endpoint-id call can't dodge a host block."""
|
||||
await clients.post("/secrets", json={"name": "tikhub", "value": "MKKEY"})
|
||||
|
||||
@@ -39,7 +39,7 @@ def _q(payload: dict) -> dict:
|
||||
# ---- registry shape ----------------------------------------------------------------------
|
||||
def test_every_provider_is_registered():
|
||||
assert set(P.REGISTRY) == {
|
||||
"google-search-console", "google-analytics", "google-business-profile",
|
||||
"google-search-console", "google-analytics", "google-business-profile", "google-tag-manager",
|
||||
"google-ads", "youtube", "linkedin", "slack", "x", "tiktok",
|
||||
"facebook", "instagram", "meta-ads",
|
||||
# API-key providers (auth_kind="key")
|
||||
@@ -61,6 +61,7 @@ def test_default_capability_is_the_broadest():
|
||||
assert P.GOOGLE_SEARCH_CONSOLE.default_capability == "write"
|
||||
assert P.X.default_capability == "write"
|
||||
assert P.GOOGLE_ADS.default_capability == "manage" # it has no read-only mode
|
||||
assert P.GOOGLE_TAG_MANAGER.default_capability == "manage"
|
||||
for provider in P.REGISTRY.values():
|
||||
caps = provider.capabilities
|
||||
if "read" in caps and "write" in caps:
|
||||
@@ -166,7 +167,8 @@ def test_slack_is_bring_your_own_bot():
|
||||
|
||||
|
||||
async def test_each_provider_uses_its_own_client_credentials(clients: AsyncClient, all_apps):
|
||||
for service, expected in (("google-search-console", "google-cid"), ("x", "x-cid")):
|
||||
for service, expected in (("google-search-console", "google-cid"),
|
||||
("google-tag-manager", "google-cid"), ("x", "x-cid")):
|
||||
q = _q((await clients.post("/oauth/start", json={"provider": service})).json())
|
||||
assert q["client_id"] == [expected], service
|
||||
|
||||
@@ -183,6 +185,24 @@ def test_satisfied_capabilities_detects_a_scope_gap():
|
||||
assert set(gsc.satisfied_capabilities(both)) == {"read", "write"}
|
||||
|
||||
|
||||
def test_google_tag_manager_capabilities_are_cumulative_and_exclude_admin():
|
||||
"""GTM can audit, prepare and publish without authority to delete an entire container or
|
||||
administer the account's users. Each wider tier must still satisfy every narrower tier."""
|
||||
gtm = P.GOOGLE_TAG_MANAGER
|
||||
assert set(gtm.scopes_for("read")) < set(gtm.scopes_for("write")) < set(gtm.scopes_for("manage"))
|
||||
assert gtm.default_capability == "manage"
|
||||
requested = {scope for scopes in gtm.scopes.values() for scope in scopes}
|
||||
assert not {
|
||||
"https://www.googleapis.com/auth/tagmanager.delete.containers",
|
||||
"https://www.googleapis.com/auth/tagmanager.manage.users",
|
||||
"https://www.googleapis.com/auth/tagmanager.manage.accounts",
|
||||
} & requested
|
||||
assert gtm.probe_path == gtm.discover_path == "/tagmanager/v2/accounts"
|
||||
assert gtm.discover_key == "account"
|
||||
assert gtm.discover_id_field == "path"
|
||||
assert gtm.discover_label_field == "name"
|
||||
|
||||
|
||||
async def test_unconfigured_providers_are_listed_but_flagged(clients: AsyncClient, monkeypatch):
|
||||
monkeypatch.setenv("TREG_X_CLIENT_ID", "")
|
||||
monkeypatch.setenv("TREG_X_CLIENT_SECRET", "")
|
||||
|
||||
Reference in New Issue
Block a user