pubship 0.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pubship/__init__.py +3 -0
- pubship/account_access.py +322 -0
- pubship/account_access_config.py +99 -0
- pubship/account_access_contracts.py +151 -0
- pubship/account_access_http.py +213 -0
- pubship/account_access_tools.py +74 -0
- pubship/appstore.py +307 -0
- pubship/appstore_config.py +68 -0
- pubship/appstore_contracts.py +226 -0
- pubship/appstore_http.py +145 -0
- pubship/appstore_tools.py +77 -0
- pubship/artifact.py +502 -0
- pubship/artifact_contracts.py +438 -0
- pubship/artifact_files.py +298 -0
- pubship/artifact_http.py +176 -0
- pubship/auth.py +113 -0
- pubship/catalog.py +94 -0
- pubship/config.py +386 -0
- pubship/contracts.json +10576 -0
- pubship/contracts.py +129 -0
- pubship/coverage.py +254 -0
- pubship/edit_lifecycle.py +216 -0
- pubship/edit_lifecycle_http.py +152 -0
- pubship/edit_writes.py +282 -0
- pubship/edit_writes_contracts.py +291 -0
- pubship/edit_writes_http.py +58 -0
- pubship/errors.py +5 -0
- pubship/hosted.py +983 -0
- pubship/hosted_administration.py +213 -0
- pubship/hosted_cancellation.py +89 -0
- pubship/hosted_catalog.py +288 -0
- pubship/hosted_config.py +172 -0
- pubship/hosted_distribution.py +305 -0
- pubship/hosted_edits.py +674 -0
- pubship/hosted_grants.py +627 -0
- pubship/hosted_pages.py +124 -0
- pubship/hosted_token_transport.py +71 -0
- pubship/hosted_tools.py +631 -0
- pubship/hosted_transfer_client.py +289 -0
- pubship/hosted_transfer_http.py +231 -0
- pubship/hosted_transfers.py +650 -0
- pubship/http.py +115 -0
- pubship/legacy_product_contracts.py +210 -0
- pubship/listing_preview.py +105 -0
- pubship/methods.json +6816 -0
- pubship/monetization.py +262 -0
- pubship/monetization_contracts.py +363 -0
- pubship/monetization_http.py +134 -0
- pubship/oauth.py +596 -0
- pubship/onetime_contracts.py +300 -0
- pubship/operator_identity.py +81 -0
- pubship/play.py +120 -0
- pubship/provider_transport.py +146 -0
- pubship/publisher_reads.py +74 -0
- pubship/publisher_reads_contracts.py +147 -0
- pubship/publishing.py +251 -0
- pubship/publishing_http.py +78 -0
- pubship/purchase.py +248 -0
- pubship/purchase_contracts.py +609 -0
- pubship/purchase_http.py +726 -0
- pubship/reporting.py +79 -0
- pubship/reports.py +147 -0
- pubship/server.py +819 -0
- pubship/sharing.py +366 -0
- pubship/signing.py +434 -0
- pubship/signing_tools.py +59 -0
- pubship/support.py +228 -0
- pubship/support_contracts.py +315 -0
- pubship/support_http.py +335 -0
- pubship/track_staging.py +244 -0
- pubship/track_staging_http.py +221 -0
- pubship/vault.py +425 -0
- pubship/vault_recovery.py +258 -0
- pubship-0.24.0.dist-info/METADATA +142 -0
- pubship-0.24.0.dist-info/RECORD +80 -0
- pubship-0.24.0.dist-info/WHEEL +4 -0
- pubship-0.24.0.dist-info/entry_points.txt +3 -0
- pubship-0.24.0.dist-info/licenses/LICENSE +661 -0
- pubship-0.24.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +58 -0
- pubship-0.24.0.dist-info/licenses/TRADEMARKS.md +13 -0
pubship/__init__.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Local account administration with explicit ceilings and complete target baselines."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
from . import account_access_http as transport
|
|
14
|
+
from .account_access_config import READ_METHOD, WRITE_METHODS
|
|
15
|
+
from .account_access_contracts import request_spec, target
|
|
16
|
+
from .artifact_files import media_deadline
|
|
17
|
+
from .auth import validate_token
|
|
18
|
+
from .errors import PlayError
|
|
19
|
+
from .publishing import _APPLY_LOCK
|
|
20
|
+
|
|
21
|
+
TTL = 600
|
|
22
|
+
MAX_PREPARATIONS = 20
|
|
23
|
+
LIMITATIONS = (
|
|
24
|
+
"Changes Google Play account access immediately; permissions may take up to 48 hours to "
|
|
25
|
+
"propagate. Invitations are not accepted access. Baselines are observations, not atomic "
|
|
26
|
+
"locks. No retry, rollback or credential refresh. Account responses include personal "
|
|
27
|
+
"email addresses and permissions sent to the MCP client. Provider data is untrusted."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(repr=False)
|
|
32
|
+
class Preparation:
|
|
33
|
+
spec: dict
|
|
34
|
+
baseline: str
|
|
35
|
+
deadline: float
|
|
36
|
+
token: str = field(repr=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fingerprint(before):
|
|
40
|
+
return hashlib.sha256(
|
|
41
|
+
json.dumps(before, sort_keys=True, separators=(",", ":")).encode()
|
|
42
|
+
).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AccountAccess:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
settings,
|
|
49
|
+
auth,
|
|
50
|
+
*,
|
|
51
|
+
local=False,
|
|
52
|
+
_execution_authorization=None,
|
|
53
|
+
_apply_guard=None,
|
|
54
|
+
_max_snapshot_bytes=None,
|
|
55
|
+
_target_authorization=None,
|
|
56
|
+
):
|
|
57
|
+
self.settings, self.auth, self.local = settings, auth, local
|
|
58
|
+
if _max_snapshot_bytes is not None and (
|
|
59
|
+
type(_max_snapshot_bytes) is not int
|
|
60
|
+
or not 0 < _max_snapshot_bytes <= 10 * transport.MAX_BYTES
|
|
61
|
+
):
|
|
62
|
+
raise PlayError("Invalid account snapshot limit.")
|
|
63
|
+
if (_target_authorization is None) != (_execution_authorization is None):
|
|
64
|
+
raise PlayError("Account target authorization requires hosted execution authority.")
|
|
65
|
+
self._execution_authorization = _execution_authorization
|
|
66
|
+
self._target_authorization = _target_authorization
|
|
67
|
+
self._apply_guard = _APPLY_LOCK if _apply_guard is None else _apply_guard
|
|
68
|
+
self._max_snapshot_bytes = _max_snapshot_bytes
|
|
69
|
+
self._preparations = {}
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
self._prepare_lock = threading.Lock()
|
|
72
|
+
|
|
73
|
+
def _local(self):
|
|
74
|
+
if self._execution_authorization is not None:
|
|
75
|
+
self._execution_authorization()
|
|
76
|
+
return
|
|
77
|
+
if self.local is not True:
|
|
78
|
+
raise PlayError("Account administration requires explicitly local services.")
|
|
79
|
+
|
|
80
|
+
def _authorize(self, spec, *, mutation=False):
|
|
81
|
+
self._local()
|
|
82
|
+
if self._target_authorization is not None:
|
|
83
|
+
self._target_authorization(deepcopy(spec), None)
|
|
84
|
+
self._local()
|
|
85
|
+
return
|
|
86
|
+
self.settings.require(spec["developer"], spec["method"], spec["user"], spec["app"])
|
|
87
|
+
if mutation:
|
|
88
|
+
self.settings.require(spec["developer"], READ_METHOD)
|
|
89
|
+
body = spec["body"] or {}
|
|
90
|
+
self.settings.permissions(
|
|
91
|
+
body.get("developerAccountPermissions", []), body.get("appLevelPermissions", [])
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _preflight(self, spec, before):
|
|
95
|
+
if before is not None and before.get("partial", False):
|
|
96
|
+
raise PlayError(
|
|
97
|
+
"Partial or owner account users cannot be safely managed via this tool."
|
|
98
|
+
)
|
|
99
|
+
existing = before
|
|
100
|
+
if spec["grant"]:
|
|
101
|
+
if before is None:
|
|
102
|
+
raise PlayError("Account grant target user does not exist.")
|
|
103
|
+
existing = next(
|
|
104
|
+
(g for g in before.get("grants", []) if g["name"] == spec["resource"]), None
|
|
105
|
+
)
|
|
106
|
+
if spec["operation"] == "create" and existing is not None:
|
|
107
|
+
raise PlayError("Account target already exists; create is not an update.")
|
|
108
|
+
if spec["operation"] != "create" and existing is None:
|
|
109
|
+
raise PlayError("Account target is absent; no mutation attempted.")
|
|
110
|
+
if self._target_authorization is not None:
|
|
111
|
+
self._target_authorization(deepcopy(spec), deepcopy(before))
|
|
112
|
+
self._local()
|
|
113
|
+
return
|
|
114
|
+
if existing is not None:
|
|
115
|
+
self.settings.permissions(
|
|
116
|
+
existing.get("developerAccountPermissions", []),
|
|
117
|
+
existing.get("appLevelPermissions", []),
|
|
118
|
+
)
|
|
119
|
+
if not spec["grant"] and spec["operation"] == "delete":
|
|
120
|
+
# Removing a user also removes every app grant; all must be within scope.
|
|
121
|
+
for grant in before.get("grants", []):
|
|
122
|
+
developer, user, app = target(grant["name"], grant=True)
|
|
123
|
+
self.settings.require(developer, spec["method"], user, app)
|
|
124
|
+
self.settings.permissions(app_permissions=grant.get("appLevelPermissions", []))
|
|
125
|
+
|
|
126
|
+
def _current(self, deadline):
|
|
127
|
+
self._local()
|
|
128
|
+
transport.fresh(deadline)
|
|
129
|
+
|
|
130
|
+
def _target_current(self, spec, deadline, before=None):
|
|
131
|
+
self._current(deadline)
|
|
132
|
+
if self._target_authorization is not None:
|
|
133
|
+
self._target_authorization(deepcopy(spec), deepcopy(before))
|
|
134
|
+
self._current(deadline)
|
|
135
|
+
|
|
136
|
+
def _options(self, deadline, spec, *, observation=False, before=None):
|
|
137
|
+
if self._execution_authorization is None and self._max_snapshot_bytes is None:
|
|
138
|
+
return {}
|
|
139
|
+
options = {"_execution_authorization": lambda: self._target_current(spec, deadline, before)}
|
|
140
|
+
if observation:
|
|
141
|
+
options["_max_snapshot_bytes"] = self._max_snapshot_bytes
|
|
142
|
+
else:
|
|
143
|
+
options["deadline"] = deadline
|
|
144
|
+
options["_max_bytes"] = min(
|
|
145
|
+
self._max_snapshot_bytes or transport.MAX_BYTES, transport.MAX_BYTES
|
|
146
|
+
)
|
|
147
|
+
return options
|
|
148
|
+
|
|
149
|
+
def _prune(self):
|
|
150
|
+
for key, preparation in list(self._preparations.items()):
|
|
151
|
+
if time.monotonic() >= preparation.deadline:
|
|
152
|
+
self._clear(self._preparations.pop(key))
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _clear(preparation):
|
|
156
|
+
preparation.token = ""
|
|
157
|
+
preparation.spec = {}
|
|
158
|
+
preparation.baseline = ""
|
|
159
|
+
|
|
160
|
+
def _dispose(self, operation_id):
|
|
161
|
+
with self._prepare_lock, self._lock:
|
|
162
|
+
self._prune()
|
|
163
|
+
preparation = self._preparations.pop(operation_id, None)
|
|
164
|
+
if preparation is not None:
|
|
165
|
+
self._clear(preparation)
|
|
166
|
+
return "disposed"
|
|
167
|
+
|
|
168
|
+
def close(self):
|
|
169
|
+
with self._prepare_lock, self._lock:
|
|
170
|
+
for preparation in self._preparations.values():
|
|
171
|
+
self._clear(preparation)
|
|
172
|
+
self._preparations.clear()
|
|
173
|
+
|
|
174
|
+
def _retained_metadata(self, operation_id):
|
|
175
|
+
with self._lock:
|
|
176
|
+
p = self._preparations[operation_id]
|
|
177
|
+
return {
|
|
178
|
+
"spec": deepcopy(p.spec),
|
|
179
|
+
"baseline": p.baseline,
|
|
180
|
+
"deadline": p.deadline,
|
|
181
|
+
"credential_bytes": len(p.token.encode()),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def read(self, method, parameters):
|
|
185
|
+
if method != READ_METHOD:
|
|
186
|
+
raise PlayError("Only users.list is an account read method.")
|
|
187
|
+
spec = request_spec(method, parameters)
|
|
188
|
+
self._authorize(spec)
|
|
189
|
+
deadline = media_deadline(time.monotonic() + TTL, self._execution_authorization)
|
|
190
|
+
token = validate_token(self.auth.token("publisher"))
|
|
191
|
+
self._target_current(spec, deadline)
|
|
192
|
+
response = transport.request(spec, token, **self._options(deadline, spec))
|
|
193
|
+
transport.validate_response(spec, response)
|
|
194
|
+
self._current(deadline)
|
|
195
|
+
return {
|
|
196
|
+
"method": method,
|
|
197
|
+
"response": response,
|
|
198
|
+
"data_scope": "account_personal_data",
|
|
199
|
+
"fetched_at": datetime.now(UTC).isoformat(),
|
|
200
|
+
"note": "One provider page; preserve nextPageToken. " + LIMITATIONS,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def prepare(self, method, parameters, body=None, acknowledge_access_effects=False):
|
|
204
|
+
if not isinstance(method, str) or method not in WRITE_METHODS:
|
|
205
|
+
raise PlayError("Method is not a supported account mutation.")
|
|
206
|
+
spec = request_spec(method, parameters, body)
|
|
207
|
+
self._authorize(spec, mutation=True)
|
|
208
|
+
if acknowledge_access_effects is not True:
|
|
209
|
+
raise PlayError("acknowledge_access_effects must be true. " + LIMITATIONS)
|
|
210
|
+
with self._prepare_lock:
|
|
211
|
+
now = time.monotonic()
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._prune()
|
|
214
|
+
if len(self._preparations) >= MAX_PREPARATIONS:
|
|
215
|
+
raise PlayError(
|
|
216
|
+
"Account preparation capacity reached; apply or wait for expiry."
|
|
217
|
+
)
|
|
218
|
+
deadline = media_deadline(now + TTL, self._execution_authorization)
|
|
219
|
+
token = validate_token(self.auth.token("publisher"))
|
|
220
|
+
self._target_current(spec, deadline)
|
|
221
|
+
before = transport.observe(
|
|
222
|
+
spec, token, deadline, **self._options(deadline, spec, observation=True)
|
|
223
|
+
)
|
|
224
|
+
self._preflight(spec, before)
|
|
225
|
+
self._current(deadline)
|
|
226
|
+
operation_id = "account_" + secrets.token_urlsafe(32)
|
|
227
|
+
with self._lock:
|
|
228
|
+
while operation_id in self._preparations:
|
|
229
|
+
operation_id = "account_" + secrets.token_urlsafe(32)
|
|
230
|
+
self._preparations[operation_id] = Preparation(
|
|
231
|
+
spec, fingerprint(before), deadline, token
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
"operation_id": operation_id,
|
|
235
|
+
"method": method,
|
|
236
|
+
"before": deepcopy(before),
|
|
237
|
+
"proposed_request": deepcopy(spec),
|
|
238
|
+
"executed": False,
|
|
239
|
+
"effects": LIMITATIONS,
|
|
240
|
+
"access_lifetime": (
|
|
241
|
+
(spec["body"] or {}).get("expirationTime", "indefinite")
|
|
242
|
+
if not spec["grant"]
|
|
243
|
+
and (
|
|
244
|
+
spec["operation"] == "create"
|
|
245
|
+
or "expirationTime" in spec["parameters"].get("updateMask", "").split(",")
|
|
246
|
+
)
|
|
247
|
+
else "unchanged"
|
|
248
|
+
),
|
|
249
|
+
"expires_at": datetime.fromtimestamp(
|
|
250
|
+
time.time() + deadline - time.monotonic(), UTC
|
|
251
|
+
).isoformat(),
|
|
252
|
+
"note": "Repeat operation_id exactly after reviewing all effects. Confirmation is not proof of human approval. Single use within ten minutes; original credential retained privately; lost on restart.",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
def apply(self, operation_id, confirmation):
|
|
256
|
+
self._local()
|
|
257
|
+
if (
|
|
258
|
+
not isinstance(operation_id, str)
|
|
259
|
+
or not re.fullmatch(r"account_[A-Za-z0-9_-]{43}", operation_id)
|
|
260
|
+
or confirmation != operation_id
|
|
261
|
+
):
|
|
262
|
+
raise PlayError("Confirmation must exactly repeat the account operation_id.")
|
|
263
|
+
with self._lock:
|
|
264
|
+
p = self._preparations.pop(operation_id, None)
|
|
265
|
+
self._prune()
|
|
266
|
+
if p is None:
|
|
267
|
+
raise PlayError("Unknown, consumed or lost account preparation.")
|
|
268
|
+
acquired = False
|
|
269
|
+
try:
|
|
270
|
+
acquired = self._apply_guard.acquire(blocking=False)
|
|
271
|
+
if not acquired:
|
|
272
|
+
raise PlayError(
|
|
273
|
+
"Another apply is in progress; this account operation ID is consumed."
|
|
274
|
+
)
|
|
275
|
+
spec = request_spec(p.spec["method"], p.spec["parameters"], p.spec["body"])
|
|
276
|
+
self._authorize(spec, mutation=True)
|
|
277
|
+
self._current(p.deadline)
|
|
278
|
+
before = transport.observe(
|
|
279
|
+
spec, p.token, p.deadline, **self._options(p.deadline, spec, observation=True)
|
|
280
|
+
)
|
|
281
|
+
self._preflight(spec, before)
|
|
282
|
+
if fingerprint(before) != p.baseline:
|
|
283
|
+
raise PlayError("Account baseline changed; no mutation attempted. Prepare again.")
|
|
284
|
+
self._current(p.deadline)
|
|
285
|
+
mutation = transport.request(
|
|
286
|
+
spec, p.token, **self._options(p.deadline, spec, before=before)
|
|
287
|
+
)
|
|
288
|
+
try:
|
|
289
|
+
transport.validate_response(spec, mutation)
|
|
290
|
+
except PlayError:
|
|
291
|
+
raise PlayError(transport.UNKNOWN_OUTCOME) from None
|
|
292
|
+
result = {
|
|
293
|
+
"operation_id": operation_id,
|
|
294
|
+
"method": spec["method"],
|
|
295
|
+
"executed": True,
|
|
296
|
+
"mutation_succeeded": True,
|
|
297
|
+
"mutation_response": mutation,
|
|
298
|
+
"permission_propagation_verified": False,
|
|
299
|
+
"note": LIMITATIONS,
|
|
300
|
+
}
|
|
301
|
+
try:
|
|
302
|
+
self._current(p.deadline)
|
|
303
|
+
after = transport.observe(
|
|
304
|
+
spec, p.token, p.deadline, **self._options(p.deadline, spec, observation=True)
|
|
305
|
+
)
|
|
306
|
+
self._current(p.deadline)
|
|
307
|
+
except Exception:
|
|
308
|
+
result.update(
|
|
309
|
+
readback_succeeded=False,
|
|
310
|
+
observation_error="Mutation succeeded but account readback failed; reconcile before preparing again.",
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
result.update(
|
|
314
|
+
readback_succeeded=True,
|
|
315
|
+
after=after,
|
|
316
|
+
observation_note="Observed resource only; effective access and permission propagation are not verified.",
|
|
317
|
+
)
|
|
318
|
+
return result
|
|
319
|
+
finally:
|
|
320
|
+
self._clear(p)
|
|
321
|
+
if acquired:
|
|
322
|
+
self._apply_guard.release()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Independent local account administration scopes and permission ceilings."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .config import PACKAGE
|
|
8
|
+
from .contracts import definitions
|
|
9
|
+
from .errors import PlayError
|
|
10
|
+
|
|
11
|
+
PREFIX = "androidpublisher."
|
|
12
|
+
READ_METHOD = PREFIX + "users.list"
|
|
13
|
+
WRITE_METHODS = frozenset(
|
|
14
|
+
PREFIX + family + "." + action
|
|
15
|
+
for family in ("users", "grants")
|
|
16
|
+
for action in ("create", "patch", "delete")
|
|
17
|
+
)
|
|
18
|
+
METHODS = WRITE_METHODS | {READ_METHOD}
|
|
19
|
+
DEVELOPER = re.compile(r"[0-9]{1,30}")
|
|
20
|
+
EMAIL = re.compile(r"[A-Za-z0-9.!#$&'*+=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?")
|
|
21
|
+
APP_ID = re.compile(r"[0-9]{1,30}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def valid_email(value):
|
|
25
|
+
return isinstance(value, str) and len(value) <= 254 and EMAIL.fullmatch(value) is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def valid_app(value):
|
|
29
|
+
return (
|
|
30
|
+
isinstance(value, str)
|
|
31
|
+
and len(value) <= 255
|
|
32
|
+
and (PACKAGE.fullmatch(value) is not None or APP_ID.fullmatch(value) is not None)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def permission_values(resource, field):
|
|
37
|
+
values = definitions()["androidpublisher"]["schemas"][resource]["properties"][field]["items"]
|
|
38
|
+
return frozenset(
|
|
39
|
+
value
|
|
40
|
+
for value, deprecated in zip(values["enum"], values["enumDeprecated"], strict=True)
|
|
41
|
+
if not deprecated and not value.endswith("_UNSPECIFIED")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AccountAccessSettings:
|
|
47
|
+
developers: frozenset[str] = frozenset()
|
|
48
|
+
methods: frozenset[str] = frozenset()
|
|
49
|
+
users: frozenset[str] = frozenset()
|
|
50
|
+
apps: frozenset[str] = frozenset()
|
|
51
|
+
developer_permissions: frozenset[str] = frozenset()
|
|
52
|
+
app_permissions: frozenset[str] = frozenset()
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
checks = (
|
|
56
|
+
(self.developers, lambda v: isinstance(v, str) and DEVELOPER.fullmatch(v)),
|
|
57
|
+
(self.methods, lambda v: v in METHODS),
|
|
58
|
+
(self.users, valid_email),
|
|
59
|
+
(self.apps, valid_app),
|
|
60
|
+
(
|
|
61
|
+
self.developer_permissions,
|
|
62
|
+
lambda v: v in permission_values("User", "developerAccountPermissions"),
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
self.app_permissions,
|
|
66
|
+
lambda v: v in permission_values("Grant", "appLevelPermissions"),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
for values, predicate in checks:
|
|
70
|
+
if not isinstance(values, frozenset) or any(not predicate(v) for v in values):
|
|
71
|
+
raise PlayError("Invalid exact account access scope or permission ceiling.")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_env(cls, base_settings=None):
|
|
75
|
+
# Deliberately independent of package, publishing, ADC and hosted settings.
|
|
76
|
+
def values(suffix):
|
|
77
|
+
raw = os.environ.get("GOOGLE_PLAY_ACCOUNT_ACCESS_" + suffix, "")
|
|
78
|
+
if not raw:
|
|
79
|
+
return frozenset()
|
|
80
|
+
parts = raw.split(",")
|
|
81
|
+
if any(not part.strip() for part in parts):
|
|
82
|
+
raise PlayError("Account access scopes require nonempty comma-separated values.")
|
|
83
|
+
return frozenset(part.strip() for part in parts)
|
|
84
|
+
|
|
85
|
+
return cls(**{name: values(name.upper()) for name in cls.__dataclass_fields__})
|
|
86
|
+
|
|
87
|
+
def require(self, developer, method, user=None, app=None):
|
|
88
|
+
if developer not in self.developers or method not in self.methods:
|
|
89
|
+
raise PlayError("Account developer and exact method require explicit local grants.")
|
|
90
|
+
if user is not None and user not in self.users:
|
|
91
|
+
raise PlayError("Account target user requires an explicit local grant.")
|
|
92
|
+
if app is not None and app not in self.apps:
|
|
93
|
+
raise PlayError("Account target app requires an explicit local grant.")
|
|
94
|
+
|
|
95
|
+
def permissions(self, developer_permissions=(), app_permissions=()):
|
|
96
|
+
if not set(developer_permissions).issubset(self.developer_permissions) or not set(
|
|
97
|
+
app_permissions
|
|
98
|
+
).issubset(self.app_permissions):
|
|
99
|
+
raise PlayError("Account operation exceeds the configured permission ceiling.")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Strict users/grants contracts supplementing the pinned provider schema."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from urllib.parse import quote, urlencode
|
|
7
|
+
|
|
8
|
+
from .account_access_config import (
|
|
9
|
+
DEVELOPER,
|
|
10
|
+
METHODS,
|
|
11
|
+
READ_METHOD,
|
|
12
|
+
permission_values,
|
|
13
|
+
valid_app,
|
|
14
|
+
valid_email,
|
|
15
|
+
)
|
|
16
|
+
from .contracts import methods, validate_request
|
|
17
|
+
from .errors import PlayError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail():
|
|
21
|
+
raise PlayError("Invalid account access target, fields or permissions.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def target(name, grant=False, parent=False):
|
|
25
|
+
if not isinstance(name, str):
|
|
26
|
+
_fail()
|
|
27
|
+
parts = name.split("/")
|
|
28
|
+
length = 2 if parent else 6 if grant else 4
|
|
29
|
+
if len(parts) != length or parts[0] != "developers" or not DEVELOPER.fullmatch(parts[1]):
|
|
30
|
+
_fail()
|
|
31
|
+
if length > 2 and (parts[2] != "users" or not valid_email(parts[3])):
|
|
32
|
+
_fail()
|
|
33
|
+
if grant and (parts[4] != "grants" or not valid_app(parts[5])):
|
|
34
|
+
_fail()
|
|
35
|
+
return parts[1], parts[3] if length > 2 else None, parts[5] if grant else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _permissions(body, field, resource):
|
|
39
|
+
if field not in body:
|
|
40
|
+
return
|
|
41
|
+
values = body[field]
|
|
42
|
+
if (
|
|
43
|
+
not isinstance(values, list)
|
|
44
|
+
or len(values) != len(set(values))
|
|
45
|
+
or not set(values).issubset(permission_values(resource, field))
|
|
46
|
+
):
|
|
47
|
+
_fail()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def request_spec(method, parameters, body=None):
|
|
51
|
+
if not isinstance(method, str) or method not in METHODS:
|
|
52
|
+
_fail()
|
|
53
|
+
validate_request(method, parameters, body)
|
|
54
|
+
parameters, body = deepcopy(parameters), deepcopy(body)
|
|
55
|
+
operation = method.rsplit(".", 1)[1]
|
|
56
|
+
grant = ".grants." in method
|
|
57
|
+
if method == READ_METHOD:
|
|
58
|
+
developer, user, app = target(parameters["parent"], parent=True)
|
|
59
|
+
if "pageSize" in parameters and (
|
|
60
|
+
type(parameters["pageSize"]) is not int or not 1 <= parameters["pageSize"] <= 1000
|
|
61
|
+
):
|
|
62
|
+
raise PlayError("Account reads support bounded pageSize values from 1 to 1000.")
|
|
63
|
+
if "pageToken" in parameters and (
|
|
64
|
+
not isinstance(parameters["pageToken"], str)
|
|
65
|
+
or not 1 <= len(parameters["pageToken"]) <= 8192
|
|
66
|
+
or not parameters["pageToken"].isprintable()
|
|
67
|
+
):
|
|
68
|
+
_fail()
|
|
69
|
+
resource = None
|
|
70
|
+
elif operation == "create":
|
|
71
|
+
developer, user, _ = target(parameters["parent"], parent=not grant)
|
|
72
|
+
resource = body.get("name")
|
|
73
|
+
bd, bu, app = target(resource, grant=grant)
|
|
74
|
+
if bd != developer or grant and bu != user:
|
|
75
|
+
_fail()
|
|
76
|
+
user = bu
|
|
77
|
+
else:
|
|
78
|
+
resource = parameters["name"]
|
|
79
|
+
developer, user, app = target(resource, grant=grant)
|
|
80
|
+
if body is not None:
|
|
81
|
+
allowed = (
|
|
82
|
+
{"name", "packageName", "appLevelPermissions"}
|
|
83
|
+
if grant
|
|
84
|
+
else {"name", "email", "expirationTime", "developerAccountPermissions"}
|
|
85
|
+
)
|
|
86
|
+
if not set(body).issubset(allowed) or body.get("name", resource) != resource:
|
|
87
|
+
_fail()
|
|
88
|
+
if grant:
|
|
89
|
+
expected = "" if app.isdigit() else app
|
|
90
|
+
if body.get("packageName", expected) != expected:
|
|
91
|
+
_fail()
|
|
92
|
+
_permissions(body, "appLevelPermissions", "Grant")
|
|
93
|
+
else:
|
|
94
|
+
if body.get("email", user) != user:
|
|
95
|
+
_fail()
|
|
96
|
+
_permissions(body, "developerAccountPermissions", "User")
|
|
97
|
+
if "expirationTime" in body:
|
|
98
|
+
stamp = body["expirationTime"]
|
|
99
|
+
if not isinstance(stamp, str) or not re.fullmatch(
|
|
100
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
|
|
101
|
+
stamp,
|
|
102
|
+
):
|
|
103
|
+
_fail()
|
|
104
|
+
try:
|
|
105
|
+
parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
_fail()
|
|
108
|
+
if parsed <= datetime.now(UTC):
|
|
109
|
+
raise PlayError("Account access expiration must be in the future.")
|
|
110
|
+
if operation == "create":
|
|
111
|
+
if not grant and body.get("email") != user:
|
|
112
|
+
_fail()
|
|
113
|
+
if operation == "patch":
|
|
114
|
+
mutable = (
|
|
115
|
+
{"appLevelPermissions"}
|
|
116
|
+
if grant
|
|
117
|
+
else {"expirationTime", "developerAccountPermissions"}
|
|
118
|
+
)
|
|
119
|
+
mask = parameters.get("updateMask")
|
|
120
|
+
if not isinstance(mask, str):
|
|
121
|
+
raise PlayError("Account patches require an explicit supported updateMask.")
|
|
122
|
+
fields = mask.split(",")
|
|
123
|
+
if not fields or len(set(fields)) != len(fields) or not set(fields).issubset(mutable):
|
|
124
|
+
_fail()
|
|
125
|
+
if not (set(body) - {"name", "email", "packageName"}).issubset(fields):
|
|
126
|
+
_fail()
|
|
127
|
+
# A mask with an omitted value deliberately resets that field.
|
|
128
|
+
definition = methods()[method]
|
|
129
|
+
path = definition["path"]
|
|
130
|
+
query = {}
|
|
131
|
+
for key, value in parameters.items():
|
|
132
|
+
if definition["parameters"][key]["location"] == "path":
|
|
133
|
+
encoded = "/".join(quote(p, safe="") for p in value.split("/"))
|
|
134
|
+
path = path.replace("{+" + key + "}", encoded)
|
|
135
|
+
else:
|
|
136
|
+
query[key] = value
|
|
137
|
+
return {
|
|
138
|
+
"method": method,
|
|
139
|
+
"http_method": definition["httpMethod"],
|
|
140
|
+
"url": "https://androidpublisher.googleapis.com/"
|
|
141
|
+
+ path
|
|
142
|
+
+ ("?" + urlencode(query) if query else ""),
|
|
143
|
+
"parameters": parameters,
|
|
144
|
+
"body": body,
|
|
145
|
+
"developer": developer,
|
|
146
|
+
"user": user,
|
|
147
|
+
"app": app,
|
|
148
|
+
"resource": resource,
|
|
149
|
+
"operation": operation,
|
|
150
|
+
"grant": grant,
|
|
151
|
+
}
|