permio 0.1.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.
permio/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """Python SDK for perms.io, permissions as a service with hierarchical resource-URI RBAC.
2
+
3
+ ::
4
+
5
+ from permio import Permio, resource
6
+
7
+ permio = Permio(project="my-app") # API key from $PERMIO_API_KEY
8
+ if permio.check("alice", "docs.read", resource("docs", "42")):
9
+ ...
10
+
11
+ See :class:`Permio` (sync) and :class:`AsyncPermio` (asyncio). The generated
12
+ stubs are available under :mod:`permio.raw`.
13
+ """
14
+
15
+ from . import raw
16
+ from ._access import (
17
+ WILDCARD_SEGMENT,
18
+ AccessEntry,
19
+ AccessGrantInfo,
20
+ AccessGrantSource,
21
+ AccessScopeInfo,
22
+ AccessScopes,
23
+ BatchCheckEntry,
24
+ CheckResult,
25
+ WhoAmI,
26
+ )
27
+ from ._apikey import API_KEY_LENGTH, is_valid_api_key, validate_api_key
28
+ from ._async_client import AsyncGroups, AsyncPermio
29
+ from ._client import Groups, Permio
30
+ from ._core import MAX_PAGE_SIZE, AddMembersResult, RemoveMembersResult
31
+ from ._errors import (
32
+ MissingPermission,
33
+ PermioAuthError,
34
+ PermioConfigError,
35
+ PermioDeniedError,
36
+ PermioError,
37
+ PermioNotFoundError,
38
+ PermioPermissionError,
39
+ PermioRateLimitError,
40
+ PermioTimeoutError,
41
+ PermioUnavailableError,
42
+ PermioValidationError,
43
+ parse_missing_permissions,
44
+ )
45
+ from ._resource import (
46
+ GROUP_PRINCIPAL_PREFIX,
47
+ MAX_RESOURCE_URI_LENGTH,
48
+ assert_resource_uri,
49
+ encode_segment,
50
+ group,
51
+ resource,
52
+ )
53
+ from ._transport import DEFAULT_BASE_URL, DEV_BASE_URL, RetryOptions, is_idempotent_method
54
+ from ._version import __version__
55
+
56
+ __all__ = [
57
+ "API_KEY_LENGTH",
58
+ "DEFAULT_BASE_URL",
59
+ "DEV_BASE_URL",
60
+ "GROUP_PRINCIPAL_PREFIX",
61
+ "MAX_PAGE_SIZE",
62
+ "MAX_RESOURCE_URI_LENGTH",
63
+ "WILDCARD_SEGMENT",
64
+ "AccessEntry",
65
+ "AccessGrantInfo",
66
+ "AccessGrantSource",
67
+ "AccessScopeInfo",
68
+ "AccessScopes",
69
+ "AddMembersResult",
70
+ "AsyncGroups",
71
+ "AsyncPermio",
72
+ "BatchCheckEntry",
73
+ "CheckResult",
74
+ "Groups",
75
+ "MissingPermission",
76
+ "Permio",
77
+ "PermioAuthError",
78
+ "PermioConfigError",
79
+ "PermioDeniedError",
80
+ "PermioError",
81
+ "PermioNotFoundError",
82
+ "PermioPermissionError",
83
+ "PermioRateLimitError",
84
+ "PermioTimeoutError",
85
+ "PermioUnavailableError",
86
+ "PermioValidationError",
87
+ "RemoveMembersResult",
88
+ "RetryOptions",
89
+ "WhoAmI",
90
+ "__version__",
91
+ "assert_resource_uri",
92
+ "encode_segment",
93
+ "group",
94
+ "is_idempotent_method",
95
+ "is_valid_api_key",
96
+ "parse_missing_permissions",
97
+ "raw",
98
+ "resource",
99
+ "validate_api_key",
100
+ ]
permio/_access.py ADDED
@@ -0,0 +1,312 @@
1
+ """Result types: access scopes, access entries, check results, whoAmI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Iterable, List, Optional, Tuple
8
+
9
+ from ._errors import MissingPermission, PermioError
10
+ from .permissions.v1 import permissions_pb2 as pb
11
+
12
+ WILDCARD_SEGMENT = "_"
13
+ """The ``_`` segment: in a ``pattern`` scope it matches exactly one segment."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CheckResult:
18
+ """Result of ``check_detailed()``."""
19
+
20
+ allowed: bool
21
+ """True when the principal holds every permission on every resource."""
22
+ missing: List[MissingPermission] = field(default_factory=list)
23
+ """What is missing, per resource, in request order. Empty when allowed."""
24
+
25
+ def __bool__(self) -> bool:
26
+ return self.allowed
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BatchCheckEntry:
31
+ """One resource's result from ``batch_check_detailed()``."""
32
+
33
+ resource: str
34
+ allowed: bool
35
+ """True when the principal holds every requested permission on ``resource``."""
36
+ missing: List[str] = field(default_factory=list)
37
+ """The requested permissions the principal lacks on ``resource``, in request order."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class WhoAmI:
42
+ """The caller, as returned by ``who_am_i()``."""
43
+
44
+ principal_id: str
45
+ """The caller's principal ID; for an API key, the key's ID."""
46
+ organisation_id: str
47
+ """The caller's organisation. Empty for a user who has not joined one yet."""
48
+ kind: str
49
+ """``"user"``, ``"api_key"``, ``"service_account"`` or ``"unknown"``."""
50
+ project: Optional[str]
51
+ """The project an API key belongs to; it can only be used there. None for users."""
52
+ api_key_access: Optional[str]
53
+ """For an API key: ``"check"`` (checks and reads), ``"full"`` (also writes, grant and
54
+ revoke) or ``"unknown"`` (its rights were changed by hand). None for other credentials."""
55
+ email: Optional[str]
56
+ """The user's email address, when the caller is a user and has one."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class AccessScopeInfo:
61
+ """One place where a principal holds a permission.
62
+
63
+ ``kind`` is ``"subtree"`` (the URI and everything below it), ``"pattern"``
64
+ (every URI whose leading segments match, where each ``_`` segment matches
65
+ exactly one segment, and everything below those), ``"exact"`` (exactly the
66
+ URI; reserved by the API, not returned today) or ``"unknown"`` (a kind this
67
+ SDK version does not know; it never matches).
68
+ """
69
+
70
+ uri: str
71
+ kind: str
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class AccessGrantSource:
76
+ """Where a grant's access comes from: ``type`` is ``"role"``, ``"direct"`` or ``"unknown"``."""
77
+
78
+ type: str
79
+ id: Optional[str] = None
80
+ """The role ID, for ``type == "role"``."""
81
+ name: Optional[str] = None
82
+ """The role name, for ``type == "role"``."""
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class AccessGrantInfo:
87
+ """One grant (or one role within a grant) that gives a principal access."""
88
+
89
+ resource: str
90
+ """The URI the grant was made on: the resource, an ancestor, or a URI with ``_`` segments matching it."""
91
+ inherited: bool
92
+ """True unless the grant was made on exactly the resource."""
93
+ match: str
94
+ """``"exact"``, ``"ancestor"``, ``"wildcard"`` or ``"unknown"``."""
95
+ source: AccessGrantSource
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class AccessEntry:
100
+ """One principal with access to a resource, returned by ``who_has_access()``."""
101
+
102
+ principal: str
103
+ """The principal ID; ``group:<slug>`` for a group."""
104
+ is_group: bool
105
+ via_group: Optional[str]
106
+ """The slug of the group through which ``principal`` has the grants in ``via``, for
107
+ entries added by ``expand_groups``. None for grants made to ``principal`` itself."""
108
+ via: List[AccessGrantInfo]
109
+ """The grants that give the access, most specific first."""
110
+
111
+
112
+ _SCOPE_KINDS = {
113
+ pb.ACCESS_SCOPE_KIND_SUBTREE: "subtree",
114
+ pb.ACCESS_SCOPE_KIND_PATTERN: "pattern",
115
+ pb.ACCESS_SCOPE_KIND_EXACT: "exact",
116
+ }
117
+
118
+ _MATCHES = {
119
+ pb.ACCESS_GRANT_MATCH_EXACT: "exact",
120
+ pb.ACCESS_GRANT_MATCH_ANCESTOR: "ancestor",
121
+ pb.ACCESS_GRANT_MATCH_WILDCARD: "wildcard",
122
+ }
123
+
124
+
125
+ def scope_kind_name(kind: pb.AccessScopeKind) -> str:
126
+ return _SCOPE_KINDS.get(kind, "unknown")
127
+
128
+
129
+ def _segments(uri: str) -> Optional[List[str]]:
130
+ """Splits "/a/b" into ["a", "b"]; None for anything that is not a well-formed URI."""
131
+ if not isinstance(uri, str) or not uri.startswith("/") or len(uri) < 2:
132
+ return None
133
+ parts = uri[1:].split("/")
134
+ return None if "" in parts else parts
135
+
136
+
137
+ def _scope_matches(scope: AccessScopeInfo, target: List[str]) -> bool:
138
+ own = _segments(scope.uri)
139
+ if own is None:
140
+ return False
141
+ if scope.kind == "exact":
142
+ return own == target
143
+ if scope.kind == "subtree":
144
+ # Segment by segment, so /a/b covers /a/b/c but not /a/bc. A "_" here is literal.
145
+ return len(own) <= len(target) and own == target[: len(own)]
146
+ if scope.kind == "pattern":
147
+ return len(own) <= len(target) and all(
148
+ s in (WILDCARD_SEGMENT, t) for s, t in zip(own, target)
149
+ )
150
+ return False
151
+
152
+
153
+ _REGEX_META = re.compile(r"[.*+?^${}()|\[\]\\/]")
154
+
155
+
156
+ def _escape(text: str) -> str:
157
+ # The same escaping as the JavaScript SDK, so the pattern source also works
158
+ # in PCRE (MongoDB $regex) and PostgreSQL regexes.
159
+ return _REGEX_META.sub(lambda m: "\\" + m.group(0), text)
160
+
161
+
162
+ def _scope_regex_source(scope: AccessScopeInfo) -> Optional[str]:
163
+ own = _segments(scope.uri)
164
+ if own is None:
165
+ return None
166
+ body = "".join(
167
+ "\\/" + ("[^/]+" if scope.kind == "pattern" and s == WILDCARD_SEGMENT else _escape(s))
168
+ for s in own
169
+ )
170
+ if scope.kind == "exact":
171
+ return body
172
+ if scope.kind in ("subtree", "pattern"):
173
+ return body + "(?:\\/.*)?"
174
+ return None
175
+
176
+
177
+ class AccessScopes:
178
+ """The answer to "where may this principal use this permission under ``under``?".
179
+
180
+ Returned by ``list_access()``; every page has already been fetched::
181
+
182
+ access = permio.list_access("alice", "docs.read", under="/docs")
183
+ visible = [d for d in docs if access.allows(resource("docs", d.id))]
184
+
185
+ The scopes describe resources at or below ``under``. A URI outside ``under``
186
+ was not asked about, so ``allows()`` returning False for it means "unknown",
187
+ not "denied". A pattern scope may also match some URIs outside ``under``
188
+ (its ``_`` segments can sit in the part that matches ``under``); those really
189
+ are allowed.
190
+ """
191
+
192
+ def __init__(self, under: str, covers_all: bool, scopes: Iterable[AccessScopeInfo]) -> None:
193
+ self.under = under
194
+ """The ``under`` URI that was asked about."""
195
+ self.covers_all = covers_all
196
+ """True when the principal holds the permission on ``under`` itself, and so on
197
+ everything below it. Check this first: when it is true, a list needs no filter."""
198
+ self.scopes: Tuple[AccessScopeInfo, ...] = tuple(scopes)
199
+ """Every scope, ordered by URI as the API returned them."""
200
+
201
+ def __repr__(self) -> str:
202
+ return f"AccessScopes(under={self.under!r}, covers_all={self.covers_all!r}, scopes={list(self.scopes)!r})"
203
+
204
+ @property
205
+ def is_empty(self) -> bool:
206
+ """True when the principal has no access anywhere under ``under``."""
207
+ return not self.covers_all and not any(s.kind != "unknown" for s in self.scopes)
208
+
209
+ def allows(self, uri: str) -> bool:
210
+ """Whether the principal holds the permission on ``uri``, according to these scopes.
211
+
212
+ Matching is per segment: a subtree scope ``/a/b`` allows ``/a/b`` and
213
+ ``/a/b/c`` but not ``/a/bc``; a pattern scope ``/a/_/c`` allows
214
+ ``/a/x/c`` and ``/a/x/c/d`` but not ``/a/x`` or ``/a/x/y``. Malformed
215
+ URIs are never allowed.
216
+ """
217
+ target = _segments(uri)
218
+ if target is None:
219
+ return False
220
+ return any(_scope_matches(s, target) for s in self.scopes)
221
+
222
+ def to_prefixes(self) -> List[str]:
223
+ """The subtree scopes' URIs, for filtering a database by path prefix.
224
+
225
+ A URI ``p`` here stands for ``p`` itself and everything below it, so
226
+ match ``path = p OR path LIKE p || '/%'``; a bare prefix match would also
227
+ match ``/docs/12`` for ``/docs/1``. Prefixes already covered by a shorter
228
+ one are dropped. Pattern scopes are not included: see ``patterns()``,
229
+ ``to_regex()`` and ``has_patterns``.
230
+ """
231
+ subtrees = list(dict.fromkeys(s.uri for s in self.scopes if s.kind == "subtree"))
232
+ return [p for p in subtrees if not any(p.startswith(q + "/") for q in subtrees)]
233
+
234
+ def patterns(self) -> List[str]:
235
+ """The pattern scopes' URIs (with ``_`` segments). Empty when every scope is a prefix."""
236
+ return [s.uri for s in self.scopes if s.kind == "pattern"]
237
+
238
+ def exact(self) -> List[str]:
239
+ """The exact scopes' URIs. The API does not return exact scopes today."""
240
+ return [s.uri for s in self.scopes if s.kind == "exact"]
241
+
242
+ @property
243
+ def has_patterns(self) -> bool:
244
+ """True when some scope is a pattern, so ``to_prefixes()`` alone does not cover everything."""
245
+ return any(s.kind == "pattern" for s in self.scopes)
246
+
247
+ def to_regex(self) -> re.Pattern[str]:
248
+ """One anchored regular expression matching exactly the URIs ``allows()`` accepts.
249
+
250
+ Use ``.pattern`` for stores that filter by regex (MongoDB ``$regex``,
251
+ PostgreSQL ``~``). With no scopes it matches nothing.
252
+ """
253
+ parts = [p for p in (_scope_regex_source(s) for s in self.scopes) if p is not None]
254
+ # "$.^" can never match, and is valid in Python, PCRE (MongoDB) and PostgreSQL regexes.
255
+ return re.compile("$.^" if not parts else "^(?:" + "|".join(parts) + ")$")
256
+
257
+
258
+ def to_access_entry(p: pb.PrincipalAccess) -> AccessEntry:
259
+ via: List[AccessGrantInfo] = []
260
+ for g in p.via:
261
+ which = g.WhichOneof("source")
262
+ if which == "role":
263
+ source = AccessGrantSource("role", g.role.id, g.role.name)
264
+ elif which == "direct":
265
+ source = AccessGrantSource("direct")
266
+ else:
267
+ source = AccessGrantSource("unknown")
268
+ via.append(
269
+ AccessGrantInfo(
270
+ resource=g.resource_uri,
271
+ inherited=g.inherited,
272
+ match=_MATCHES.get(g.match, "unknown"),
273
+ source=source,
274
+ )
275
+ )
276
+ return AccessEntry(
277
+ principal=p.principal_id,
278
+ is_group=p.is_group,
279
+ via_group=p.via_group or None,
280
+ via=via,
281
+ )
282
+
283
+
284
+ def inconsistent_response(what: str) -> PermioError:
285
+ """The API answered in a way the SDK cannot trust, e.g. a batch result for an unrequested resource."""
286
+ return PermioError(f"permio: inconsistent response from the API: {what}")
287
+
288
+
289
+ _PRINCIPAL_KINDS = {
290
+ pb.PRINCIPAL_TYPE_USER: "user",
291
+ pb.PRINCIPAL_TYPE_API_KEY: "api_key",
292
+ pb.PRINCIPAL_TYPE_SERVICE_ACCOUNT: "service_account",
293
+ }
294
+
295
+ _API_KEY_ACCESS = {
296
+ pb.API_KEY_ACCESS_CHECK: "check",
297
+ pb.API_KEY_ACCESS_FULL: "full",
298
+ }
299
+
300
+
301
+ def to_who_am_i(res: pb.WhoAmIResponse) -> WhoAmI:
302
+ kind = _PRINCIPAL_KINDS.get(res.principal_type, "unknown")
303
+ return WhoAmI(
304
+ principal_id=res.principal_id,
305
+ organisation_id=res.organisation_id,
306
+ kind=kind,
307
+ project=res.project_name or None,
308
+ api_key_access=(
309
+ _API_KEY_ACCESS.get(res.api_key_access, "unknown") if kind == "api_key" else None
310
+ ),
311
+ email=res.email or None,
312
+ )
permio/_apikey.py ADDED
@@ -0,0 +1,73 @@
1
+ """Local API key format checks (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import zlib
7
+
8
+ from ._errors import PermioConfigError
9
+
10
+ # Mirrors go-common/middleware/apikey: "perms_" + 32 random base62 characters +
11
+ # 6 base62 characters encoding the CRC32 (IEEE) of those 32, 44 characters total.
12
+ _PREFIX = "perms_"
13
+ _RANDOM_LENGTH = 32
14
+ _CHECKSUM_LENGTH = 6
15
+ API_KEY_LENGTH = len(_PREFIX) + _RANDOM_LENGTH + _CHECKSUM_LENGTH
16
+ _ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
17
+ _ALPHABET_SET = frozenset(_ALPHABET)
18
+
19
+ _JWT = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
20
+
21
+
22
+ def _checksum(body: str) -> str:
23
+ value = zlib.crc32(body.encode("ascii")) & 0xFFFFFFFF
24
+ out = []
25
+ for _ in range(_CHECKSUM_LENGTH):
26
+ value, rem = divmod(value, 62)
27
+ out.append(_ALPHABET[rem])
28
+ return "".join(reversed(out))
29
+
30
+
31
+ def validate_api_key(key: object) -> str:
32
+ """Checks that ``key`` looks like a perms.io API key and returns its kind.
33
+
34
+ Returns ``"opaque"`` for ``perms_...`` keys (checked for length, alphabet and
35
+ CRC32 checksum, so a typo is caught before any request is made) and
36
+ ``"jwt"`` for legacy JWT keys (only checked for the three-part shape). This
37
+ does not tell you whether the key exists or is still valid; only the API can.
38
+
39
+ Raises:
40
+ PermioConfigError: if the key is missing or malformed.
41
+ """
42
+ if not isinstance(key, str) or key == "":
43
+ raise PermioConfigError("permio: api_key is required (or set PERMIO_API_KEY)")
44
+ if key.startswith(_PREFIX):
45
+ if len(key) != API_KEY_LENGTH:
46
+ raise PermioConfigError(
47
+ f"permio: malformed API key (expected {API_KEY_LENGTH} characters starting with "
48
+ f'"{_PREFIX}", got {len(key)})'
49
+ )
50
+ rest = key[len(_PREFIX) :]
51
+ if any(c not in _ALPHABET_SET for c in rest):
52
+ raise PermioConfigError(
53
+ "permio: malformed API key (characters after perms_ must be 0-9, A-Z or a-z)"
54
+ )
55
+ if _checksum(rest[:_RANDOM_LENGTH]) != rest[_RANDOM_LENGTH:]:
56
+ raise PermioConfigError(
57
+ "permio: API key checksum mismatch; check it was copied correctly"
58
+ )
59
+ return "opaque"
60
+ if _JWT.match(key):
61
+ return "jwt"
62
+ raise PermioConfigError(
63
+ 'permio: malformed API key (expected a "perms_" key from the dashboard, or a legacy JWT key)'
64
+ )
65
+
66
+
67
+ def is_valid_api_key(key: object) -> bool:
68
+ """Returns True if ``key`` passes :func:`validate_api_key`."""
69
+ try:
70
+ validate_api_key(key)
71
+ except PermioConfigError:
72
+ return False
73
+ return True