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 +100 -0
- permio/_access.py +312 -0
- permio/_apikey.py +73 -0
- permio/_async_client.py +747 -0
- permio/_client.py +735 -0
- permio/_core.py +336 -0
- permio/_errors.py +268 -0
- permio/_resource.py +99 -0
- permio/_transport.py +313 -0
- permio/_version.py +1 -0
- permio/organisation/__init__.py +0 -0
- permio/organisation/v1/__init__.py +0 -0
- permio/organisation/v1/organisation_pb2.py +233 -0
- permio/organisation/v1/organisation_pb2.pyi +328 -0
- permio/organisation/v1/organisation_pb2_grpc.py +802 -0
- permio/pagination/__init__.py +0 -0
- permio/pagination/v1/__init__.py +0 -0
- permio/pagination/v1/pagination_pb2.py +45 -0
- permio/pagination/v1/pagination_pb2.pyi +23 -0
- permio/pagination/v1/pagination_pb2_grpc.py +4 -0
- permio/permissions/__init__.py +0 -0
- permio/permissions/v1/__init__.py +0 -0
- permio/permissions/v1/permissions_pb2.py +398 -0
- permio/permissions/v1/permissions_pb2.pyi +665 -0
- permio/permissions/v1/permissions_pb2_grpc.py +1466 -0
- permio/py.typed +0 -0
- permio/raw.py +34 -0
- permio/validate/__init__.py +0 -0
- permio/validate/validate_pb2.py +86 -0
- permio/validate/validate_pb2.pyi +497 -0
- permio-0.1.0.dist-info/METADATA +399 -0
- permio-0.1.0.dist-info/RECORD +35 -0
- permio-0.1.0.dist-info/WHEEL +5 -0
- permio-0.1.0.dist-info/licenses/LICENSE +202 -0
- permio-0.1.0.dist-info/top_level.txt +1 -0
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
|