grantr 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.
- grantr/__init__.py +84 -0
- grantr/core/__init__.py +123 -0
- grantr/core/audit.py +242 -0
- grantr/core/errors.py +13 -0
- grantr/core/grants.py +98 -0
- grantr/core/ids.py +31 -0
- grantr/core/keys.py +59 -0
- grantr/core/resources.py +67 -0
- grantr/core/roles.py +332 -0
- grantr/core/subjects.py +71 -0
- grantr/fastapi/__init__.py +30 -0
- grantr/fastapi/openapi.py +45 -0
- grantr/fastapi/router.py +541 -0
- grantr/grants/__init__.py +19 -0
- grantr/grants/grants.py +991 -0
- grantr/grants/memory.py +299 -0
- grantr/grants/storage.py +200 -0
- grantr/py.typed +0 -0
- grantr/sql/__init__.py +18 -0
- grantr/sql/migrations/env.py +17 -0
- grantr/sql/migrations/script.py.mako +26 -0
- grantr/sql/migrations/versions/0001_initial.py +124 -0
- grantr/sql/migrations/versions/0002_resources.py +46 -0
- grantr/sql/migrations/versions/0003_row_level_security.py +65 -0
- grantr/sql/migrations/versions/0004_audit_filters.py +117 -0
- grantr/sql/migrations/versions/0005_tenant_creation_in_the_deployment_log.py +48 -0
- grantr/sql/schema.py +113 -0
- grantr/sql/sqlite.py +18 -0
- grantr/sql/storage.py +728 -0
- grantr/sql/tables.py +163 -0
- grantr/sql/tenancy.py +18 -0
- grantr/telemetry/__init__.py +105 -0
- grantr/testing/__init__.py +41 -0
- grantr-0.1.0.dist-info/METADATA +111 -0
- grantr-0.1.0.dist-info/RECORD +37 -0
- grantr-0.1.0.dist-info/WHEEL +4 -0
- grantr-0.1.0.dist-info/licenses/LICENSE +21 -0
grantr/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""grantr: identity and access for artifactr, reflexr and portalr.
|
|
2
|
+
|
|
3
|
+
Tenants, their members, groups, roles and service principals, and the grants that bind them to
|
|
4
|
+
tenants, workspaces, threads, artifacts and rules. The libraries ask it, through their own hooks,
|
|
5
|
+
before every command and read; it imports neither library, and neither imports it.
|
|
6
|
+
|
|
7
|
+
The model and its rules are in :mod:`grantr.core`, with no I/O; :class:`Grants` answers checks
|
|
8
|
+
and changes access over a storage, such as :class:`InMemoryStorage`. The documentation, with its
|
|
9
|
+
decisions and the plan that builds grantr, is at <https://lattice.alexnodeland.com/grantr/>.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
|
|
14
|
+
from grantr.core import (
|
|
15
|
+
DEPLOYMENT,
|
|
16
|
+
TENANT,
|
|
17
|
+
Agent,
|
|
18
|
+
AuditEntry,
|
|
19
|
+
AuditEvent,
|
|
20
|
+
AuditFilter,
|
|
21
|
+
AuditType,
|
|
22
|
+
BuiltInRole,
|
|
23
|
+
CatalogView,
|
|
24
|
+
Forbidden,
|
|
25
|
+
Grant,
|
|
26
|
+
Grantee,
|
|
27
|
+
GrantrError,
|
|
28
|
+
Group,
|
|
29
|
+
Invalid,
|
|
30
|
+
Member,
|
|
31
|
+
Membership,
|
|
32
|
+
Resource,
|
|
33
|
+
ResourceKind,
|
|
34
|
+
Role,
|
|
35
|
+
RulePrincipal,
|
|
36
|
+
ServicePrincipal,
|
|
37
|
+
Subject,
|
|
38
|
+
TenantId,
|
|
39
|
+
User,
|
|
40
|
+
UserId,
|
|
41
|
+
Visibility,
|
|
42
|
+
WorkspaceId,
|
|
43
|
+
)
|
|
44
|
+
from grantr.grants import Access, Grants, Hooks, InMemoryStorage, Storage, SubjectOf
|
|
45
|
+
|
|
46
|
+
__version__ = version("grantr")
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DEPLOYMENT",
|
|
50
|
+
"TENANT",
|
|
51
|
+
"Access",
|
|
52
|
+
"Agent",
|
|
53
|
+
"AuditEntry",
|
|
54
|
+
"AuditEvent",
|
|
55
|
+
"AuditFilter",
|
|
56
|
+
"AuditType",
|
|
57
|
+
"BuiltInRole",
|
|
58
|
+
"CatalogView",
|
|
59
|
+
"Forbidden",
|
|
60
|
+
"Grant",
|
|
61
|
+
"Grantee",
|
|
62
|
+
"GrantrError",
|
|
63
|
+
"Grants",
|
|
64
|
+
"Group",
|
|
65
|
+
"Hooks",
|
|
66
|
+
"InMemoryStorage",
|
|
67
|
+
"Invalid",
|
|
68
|
+
"Member",
|
|
69
|
+
"Membership",
|
|
70
|
+
"Resource",
|
|
71
|
+
"ResourceKind",
|
|
72
|
+
"Role",
|
|
73
|
+
"RulePrincipal",
|
|
74
|
+
"ServicePrincipal",
|
|
75
|
+
"Storage",
|
|
76
|
+
"Subject",
|
|
77
|
+
"SubjectOf",
|
|
78
|
+
"TenantId",
|
|
79
|
+
"User",
|
|
80
|
+
"UserId",
|
|
81
|
+
"Visibility",
|
|
82
|
+
"WorkspaceId",
|
|
83
|
+
"__version__",
|
|
84
|
+
]
|
grantr/core/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""grantr's model, and the rules a check and a grant follow: no I/O, no async.
|
|
2
|
+
|
|
3
|
+
:class:`~grantr.grants.Grants` loads what a decision needs from storage, and decides with these.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from grantr.core.audit import (
|
|
7
|
+
AuditEntry,
|
|
8
|
+
AuditEvent,
|
|
9
|
+
AuditFilter,
|
|
10
|
+
AuditType,
|
|
11
|
+
DeploymentTenantCreated,
|
|
12
|
+
Granted,
|
|
13
|
+
GroupDefined,
|
|
14
|
+
GroupDeleted,
|
|
15
|
+
KeyIssued,
|
|
16
|
+
KeyRevoked,
|
|
17
|
+
MemberAdded,
|
|
18
|
+
MemberRemoved,
|
|
19
|
+
Restricted,
|
|
20
|
+
Revoked,
|
|
21
|
+
RoleDefined,
|
|
22
|
+
RoleDeleted,
|
|
23
|
+
TenantCreated,
|
|
24
|
+
TenantDeleted,
|
|
25
|
+
Unrestricted,
|
|
26
|
+
)
|
|
27
|
+
from grantr.core.errors import Forbidden, GrantrError, Invalid
|
|
28
|
+
from grantr.core.grants import Grant, Membership, Visibility, memberships
|
|
29
|
+
from grantr.core.ids import PRINCIPAL_ID, TENANT_ID, PrincipalId, TenantId, UserId, WorkspaceId
|
|
30
|
+
from grantr.core.keys import Key, issue, key_id, verify
|
|
31
|
+
from grantr.core.resources import (
|
|
32
|
+
DEPLOYMENT,
|
|
33
|
+
ITEMS,
|
|
34
|
+
TENANT,
|
|
35
|
+
ListableKind,
|
|
36
|
+
Resource,
|
|
37
|
+
ResourceKind,
|
|
38
|
+
)
|
|
39
|
+
from grantr.core.roles import (
|
|
40
|
+
APPLICATION,
|
|
41
|
+
BUILT_IN,
|
|
42
|
+
GRANTR_ACTIONS,
|
|
43
|
+
MEMBER,
|
|
44
|
+
OPERATOR,
|
|
45
|
+
BuiltInRole,
|
|
46
|
+
Catalog,
|
|
47
|
+
CatalogView,
|
|
48
|
+
Level,
|
|
49
|
+
Role,
|
|
50
|
+
)
|
|
51
|
+
from grantr.core.subjects import (
|
|
52
|
+
Agent,
|
|
53
|
+
Grantee,
|
|
54
|
+
Group,
|
|
55
|
+
Member,
|
|
56
|
+
RulePrincipal,
|
|
57
|
+
ServicePrincipal,
|
|
58
|
+
Subject,
|
|
59
|
+
User,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"APPLICATION",
|
|
64
|
+
"BUILT_IN",
|
|
65
|
+
"DEPLOYMENT",
|
|
66
|
+
"GRANTR_ACTIONS",
|
|
67
|
+
"ITEMS",
|
|
68
|
+
"MEMBER",
|
|
69
|
+
"OPERATOR",
|
|
70
|
+
"PRINCIPAL_ID",
|
|
71
|
+
"TENANT",
|
|
72
|
+
"TENANT_ID",
|
|
73
|
+
"Agent",
|
|
74
|
+
"AuditEntry",
|
|
75
|
+
"AuditEvent",
|
|
76
|
+
"AuditFilter",
|
|
77
|
+
"AuditType",
|
|
78
|
+
"BuiltInRole",
|
|
79
|
+
"Catalog",
|
|
80
|
+
"CatalogView",
|
|
81
|
+
"DeploymentTenantCreated",
|
|
82
|
+
"Forbidden",
|
|
83
|
+
"Grant",
|
|
84
|
+
"Granted",
|
|
85
|
+
"Grantee",
|
|
86
|
+
"GrantrError",
|
|
87
|
+
"Group",
|
|
88
|
+
"GroupDefined",
|
|
89
|
+
"GroupDeleted",
|
|
90
|
+
"Invalid",
|
|
91
|
+
"Key",
|
|
92
|
+
"KeyIssued",
|
|
93
|
+
"KeyRevoked",
|
|
94
|
+
"Level",
|
|
95
|
+
"ListableKind",
|
|
96
|
+
"Member",
|
|
97
|
+
"MemberAdded",
|
|
98
|
+
"MemberRemoved",
|
|
99
|
+
"Membership",
|
|
100
|
+
"PrincipalId",
|
|
101
|
+
"Resource",
|
|
102
|
+
"ResourceKind",
|
|
103
|
+
"Restricted",
|
|
104
|
+
"Revoked",
|
|
105
|
+
"Role",
|
|
106
|
+
"RoleDefined",
|
|
107
|
+
"RoleDeleted",
|
|
108
|
+
"RulePrincipal",
|
|
109
|
+
"ServicePrincipal",
|
|
110
|
+
"Subject",
|
|
111
|
+
"TenantCreated",
|
|
112
|
+
"TenantDeleted",
|
|
113
|
+
"TenantId",
|
|
114
|
+
"Unrestricted",
|
|
115
|
+
"User",
|
|
116
|
+
"UserId",
|
|
117
|
+
"Visibility",
|
|
118
|
+
"WorkspaceId",
|
|
119
|
+
"issue",
|
|
120
|
+
"key_id",
|
|
121
|
+
"memberships",
|
|
122
|
+
"verify",
|
|
123
|
+
]
|
grantr/core/audit.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""The audit log's events: every change to access, typed.
|
|
2
|
+
|
|
3
|
+
grantr appends one entry per change, in the same transaction as the change, with a sequence
|
|
4
|
+
number, the time, the tenant and who made it. Refusals aren't recorded. A key's secret, and its
|
|
5
|
+
digest, never are. A read of the log may keep only some types of event, or one subject's
|
|
6
|
+
changes (:class:`AuditFilter`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Set
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Annotated, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from grantr.core.grants import Grant
|
|
17
|
+
from grantr.core.ids import TenantId, UserId
|
|
18
|
+
from grantr.core.resources import Resource
|
|
19
|
+
from grantr.core.roles import Role
|
|
20
|
+
from grantr.core.subjects import Member, ServicePrincipal, Subject
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TenantCreated(BaseModel):
|
|
24
|
+
"""A tenant was created, with its first owner."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
type: Literal["tenant_created"] = "tenant_created"
|
|
29
|
+
owner: UserId
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeploymentTenantCreated(BaseModel):
|
|
33
|
+
"""A tenant was created: the deployment's record of it, beside the tenant's own.
|
|
34
|
+
|
|
35
|
+
Its entry is in no tenant's log, the deployment's, and names who created the tenant, so an
|
|
36
|
+
operator who isn't a member of the tenant it created is seen to have created it. The owner is
|
|
37
|
+
a user's id, never an email.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True)
|
|
41
|
+
|
|
42
|
+
type: Literal["deployment_tenant_created"] = "deployment_tenant_created"
|
|
43
|
+
tenant: TenantId
|
|
44
|
+
owner: UserId
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TenantDeleted(BaseModel):
|
|
48
|
+
"""A tenant was deleted, with everything grantr held for it but its audit log."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(frozen=True)
|
|
51
|
+
|
|
52
|
+
type: Literal["tenant_deleted"] = "tenant_deleted"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MemberAdded(BaseModel):
|
|
56
|
+
"""A user or service principal joined the tenant."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True)
|
|
59
|
+
|
|
60
|
+
type: Literal["member_added"] = "member_added"
|
|
61
|
+
member: Member
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MemberRemoved(BaseModel):
|
|
65
|
+
"""A member left the tenant, with its grants, its places in groups, and its keys."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(frozen=True)
|
|
68
|
+
|
|
69
|
+
type: Literal["member_removed"] = "member_removed"
|
|
70
|
+
member: Member
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GroupDefined(BaseModel):
|
|
74
|
+
"""A group was created, or its members replaced."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True)
|
|
77
|
+
|
|
78
|
+
type: Literal["group_defined"] = "group_defined"
|
|
79
|
+
group: str
|
|
80
|
+
members: frozenset[Member]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GroupDeleted(BaseModel):
|
|
84
|
+
"""A group was deleted, with its grants."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(frozen=True)
|
|
87
|
+
|
|
88
|
+
type: Literal["group_deleted"] = "group_deleted"
|
|
89
|
+
group: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RoleDefined(BaseModel):
|
|
93
|
+
"""A custom role was created, or its actions replaced."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(frozen=True)
|
|
96
|
+
|
|
97
|
+
type: Literal["role_defined"] = "role_defined"
|
|
98
|
+
role: Role
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RoleDeleted(BaseModel):
|
|
102
|
+
"""A custom role was deleted, with its grants."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(frozen=True)
|
|
105
|
+
|
|
106
|
+
type: Literal["role_deleted"] = "role_deleted"
|
|
107
|
+
role: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Granted(BaseModel):
|
|
111
|
+
"""A role was granted."""
|
|
112
|
+
|
|
113
|
+
model_config = ConfigDict(frozen=True)
|
|
114
|
+
|
|
115
|
+
type: Literal["granted"] = "granted"
|
|
116
|
+
grant: Grant
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Revoked(BaseModel):
|
|
120
|
+
"""A grant was revoked."""
|
|
121
|
+
|
|
122
|
+
model_config = ConfigDict(frozen=True)
|
|
123
|
+
|
|
124
|
+
type: Literal["revoked"] = "revoked"
|
|
125
|
+
grant: Grant
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class KeyIssued(BaseModel):
|
|
129
|
+
"""A key was issued to a service principal."""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(frozen=True)
|
|
132
|
+
|
|
133
|
+
type: Literal["key_issued"] = "key_issued"
|
|
134
|
+
principal: ServicePrincipal
|
|
135
|
+
key: str
|
|
136
|
+
"""The key's public id."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class KeyRevoked(BaseModel):
|
|
140
|
+
"""A key was revoked."""
|
|
141
|
+
|
|
142
|
+
model_config = ConfigDict(frozen=True)
|
|
143
|
+
|
|
144
|
+
type: Literal["key_revoked"] = "key_revoked"
|
|
145
|
+
principal: ServicePrincipal
|
|
146
|
+
key: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Restricted(BaseModel):
|
|
150
|
+
"""A thread or artifact was restricted to those granted a role on it."""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(frozen=True)
|
|
153
|
+
|
|
154
|
+
type: Literal["restricted"] = "restricted"
|
|
155
|
+
on: Resource
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Unrestricted(BaseModel):
|
|
159
|
+
"""A thread or artifact's restriction was lifted: its workspace's roles hold on it again."""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(frozen=True)
|
|
162
|
+
|
|
163
|
+
type: Literal["unrestricted"] = "unrestricted"
|
|
164
|
+
on: Resource
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
type AuditEvent = Annotated[
|
|
168
|
+
TenantCreated
|
|
169
|
+
| DeploymentTenantCreated
|
|
170
|
+
| TenantDeleted
|
|
171
|
+
| MemberAdded
|
|
172
|
+
| MemberRemoved
|
|
173
|
+
| GroupDefined
|
|
174
|
+
| GroupDeleted
|
|
175
|
+
| RoleDefined
|
|
176
|
+
| RoleDeleted
|
|
177
|
+
| Granted
|
|
178
|
+
| Revoked
|
|
179
|
+
| KeyIssued
|
|
180
|
+
| KeyRevoked
|
|
181
|
+
| Restricted
|
|
182
|
+
| Unrestricted,
|
|
183
|
+
Field(discriminator="type"),
|
|
184
|
+
]
|
|
185
|
+
"""A change to access."""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AuditEntry(BaseModel):
|
|
189
|
+
"""An event in the audit log, with its place in it."""
|
|
190
|
+
|
|
191
|
+
model_config = ConfigDict(frozen=True)
|
|
192
|
+
|
|
193
|
+
seq: int
|
|
194
|
+
"""Its place in the deployment's one log, from 1."""
|
|
195
|
+
|
|
196
|
+
at: datetime
|
|
197
|
+
tenant: TenantId | None
|
|
198
|
+
"""The tenant it changed; ``None`` for the deployment's operators, and a tenant's creation."""
|
|
199
|
+
|
|
200
|
+
by: Subject | None
|
|
201
|
+
"""Who made the change; ``None`` for the first operator, whom no one granted."""
|
|
202
|
+
|
|
203
|
+
event: AuditEvent
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
type AuditType = Literal[
|
|
207
|
+
"tenant_created",
|
|
208
|
+
"deployment_tenant_created",
|
|
209
|
+
"tenant_deleted",
|
|
210
|
+
"member_added",
|
|
211
|
+
"member_removed",
|
|
212
|
+
"group_defined",
|
|
213
|
+
"group_deleted",
|
|
214
|
+
"role_defined",
|
|
215
|
+
"role_deleted",
|
|
216
|
+
"granted",
|
|
217
|
+
"revoked",
|
|
218
|
+
"key_issued",
|
|
219
|
+
"key_revoked",
|
|
220
|
+
"restricted",
|
|
221
|
+
"unrestricted",
|
|
222
|
+
]
|
|
223
|
+
"""An audit event's ``type``: what a read of the log may keep."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class AuditFilter:
|
|
228
|
+
"""Which entries a read of the log keeps: of some types, by one subject, or every one.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
types: The event types to keep; every type when empty.
|
|
232
|
+
by: The subject whose changes to keep, such as a user or a rule; anyone's when None.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
types: Set[AuditType] = frozenset()
|
|
236
|
+
by: Subject | None = None
|
|
237
|
+
|
|
238
|
+
def keeps(self, entry: AuditEntry) -> bool:
|
|
239
|
+
"""Whether an entry passes the filter."""
|
|
240
|
+
return (not self.types or entry.event.type in self.types) and (
|
|
241
|
+
self.by is None or entry.by == self.by
|
|
242
|
+
)
|
grantr/core/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Why grantr refuses a command that changes access."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GrantrError(Exception):
|
|
5
|
+
"""A command grantr refused. Checks never raise: they answer ``False``."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Forbidden(GrantrError):
|
|
9
|
+
"""The actor may not do this: it lacks the action, or holds less than it would give."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Invalid(GrantrError):
|
|
13
|
+
"""The command can't apply: an unknown tenant, role, group or key, or a duplicate."""
|
grantr/core/grants.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Grants, and what they hold: the rule every check follows.
|
|
2
|
+
|
|
3
|
+
A grant is a role, on a resource, to a grantee. A grant on a resource holds on everything in
|
|
4
|
+
it: a workspace's grants hold on its threads, artifacts and rules, and a tenant's on its
|
|
5
|
+
workspaces. A grant on an item adds to the workspace's.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from itertools import groupby
|
|
10
|
+
from typing import Self
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
13
|
+
|
|
14
|
+
from grantr.core.ids import TenantId, UserId
|
|
15
|
+
from grantr.core.resources import Resource
|
|
16
|
+
from grantr.core.roles import RESTRICT, Catalog, Role
|
|
17
|
+
from grantr.core.subjects import Grantee, User
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Grant(BaseModel):
|
|
21
|
+
"""A role, on a resource, to a grantee: in a tenant, or on the deployment."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True)
|
|
24
|
+
|
|
25
|
+
tenant: TenantId | None
|
|
26
|
+
"""The tenant it is in; ``None`` for a grant on the deployment, which is in none."""
|
|
27
|
+
|
|
28
|
+
role: str
|
|
29
|
+
on: Resource
|
|
30
|
+
to: Grantee
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _only_the_deployment_is_in_no_tenant(self) -> Self:
|
|
34
|
+
if (self.tenant is None) != (self.on.kind == "deployment"):
|
|
35
|
+
raise ValueError("a grant is in a tenant, unless it is on the deployment")
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Membership(BaseModel):
|
|
40
|
+
"""A user's place in a tenant: the roles granted to them on it."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(frozen=True)
|
|
43
|
+
|
|
44
|
+
tenant: TenantId
|
|
45
|
+
user: UserId
|
|
46
|
+
roles: tuple[str, ...]
|
|
47
|
+
"""Their tenant roles, by name, sorted: ``member``, and ``owner`` or ``admin`` if held."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Visibility(BaseModel):
|
|
51
|
+
"""The threads and artifacts an actor may not see in one workspace.
|
|
52
|
+
|
|
53
|
+
It has the fields of artifactr's ``Visibility``, which grantr doesn't import.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
57
|
+
|
|
58
|
+
threads: frozenset[str] = frozenset()
|
|
59
|
+
artifacts: frozenset[str] = frozenset()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def held(
|
|
63
|
+
grants: Iterable[Grant],
|
|
64
|
+
on: Resource,
|
|
65
|
+
catalog: Catalog,
|
|
66
|
+
custom: Mapping[str, Role],
|
|
67
|
+
*,
|
|
68
|
+
restricted: bool = False,
|
|
69
|
+
) -> frozenset[str]:
|
|
70
|
+
"""The actions some grants hold on a resource: those of each grant on it or around it.
|
|
71
|
+
|
|
72
|
+
On a restricted thread or artifact, a grant around it holds only if its role may restrict:
|
|
73
|
+
the workspace's admins, and the tenant's owners and admins. Grants on the item itself hold
|
|
74
|
+
as ever.
|
|
75
|
+
"""
|
|
76
|
+
chain = on.chain()
|
|
77
|
+
found: set[str] = set()
|
|
78
|
+
for grant in grants:
|
|
79
|
+
if grant.on not in chain:
|
|
80
|
+
continue
|
|
81
|
+
actions = catalog.role(grant.role, grant.on.kind, custom) or frozenset[str]()
|
|
82
|
+
if restricted and grant.on != on and RESTRICT not in actions:
|
|
83
|
+
continue
|
|
84
|
+
found |= actions
|
|
85
|
+
return frozenset(found)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def memberships(user: UserId, grants: Iterable[Grant]) -> list[Membership]:
|
|
89
|
+
"""A user's memberships, from the grants on tenants made to them directly, by tenant."""
|
|
90
|
+
tenant_roles = sorted(
|
|
91
|
+
(grant.tenant, grant.role)
|
|
92
|
+
for grant in grants
|
|
93
|
+
if grant.tenant is not None and grant.on.kind == "tenant" and grant.to == User(id=user)
|
|
94
|
+
)
|
|
95
|
+
return [
|
|
96
|
+
Membership(tenant=tenant, user=user, roles=tuple(role for _, role in roles))
|
|
97
|
+
for tenant, roles in groupby(tenant_roles, key=lambda pair: pair[0])
|
|
98
|
+
]
|
grantr/core/ids.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The ids grantr shares with the libraries, and its own.
|
|
2
|
+
|
|
3
|
+
A tenant and a workspace have the same ids as in artifactr and reflexr. A user is a Supabase Auth
|
|
4
|
+
``sub``: grantr stores the id, and no password or profile.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Annotated, Final
|
|
9
|
+
|
|
10
|
+
from pydantic import StringConstraints
|
|
11
|
+
|
|
12
|
+
TENANT_ID: Final = re.compile(r"^[^~]+$")
|
|
13
|
+
"""A tenant id's form."""
|
|
14
|
+
|
|
15
|
+
type TenantId = Annotated[str, StringConstraints(pattern=TENANT_ID.pattern)]
|
|
16
|
+
"""The isolation boundary, as the libraries name it: not empty, and with no ``~``, which
|
|
17
|
+
grantr's storage keeps for the deployment."""
|
|
18
|
+
|
|
19
|
+
PRINCIPAL_ID: Final = re.compile(r"^[^:~\s]+$")
|
|
20
|
+
"""A service principal's id's form."""
|
|
21
|
+
|
|
22
|
+
type PrincipalId = Annotated[str, StringConstraints(pattern=PRINCIPAL_ID.pattern)]
|
|
23
|
+
"""A service principal: not empty, and with no ``:``, ``~`` or whitespace. The ids the
|
|
24
|
+
libraries' own actors take hold a ``:``, as relayr's code rules' do (``reflexr:<rule>``), so no
|
|
25
|
+
principal a tenant makes, and no key it issues, can pass for one (grantr ADR-0028)."""
|
|
26
|
+
|
|
27
|
+
type WorkspaceId = str
|
|
28
|
+
"""A workspace in a tenant, as the libraries name it."""
|
|
29
|
+
|
|
30
|
+
type UserId = str
|
|
31
|
+
"""A person, by the identity provider's id for them."""
|
grantr/core/keys.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Service principal keys: ``grantr_sk_<id>_<secret>``, shown once.
|
|
2
|
+
|
|
3
|
+
A key is a public id and 32 random bytes, both in hex. grantr stores the id, the key's tenant
|
|
4
|
+
and principal, and a SHA-256 digest of the secret: 32 random bytes need no salt. A key is found
|
|
5
|
+
by its id and checked by comparing digests in constant time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
from grantr.core.ids import TenantId
|
|
17
|
+
from grantr.core.subjects import ServicePrincipal
|
|
18
|
+
|
|
19
|
+
KEY: Final = re.compile(r"grantr_sk_([0-9a-f]{16})_([0-9a-f]{64})")
|
|
20
|
+
"""A key's form: the prefix, an 8-byte id and a 32-byte secret, in hex."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Key(BaseModel):
|
|
24
|
+
"""What grantr stores of a key: never its secret."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
tenant: TenantId
|
|
30
|
+
principal: ServicePrincipal
|
|
31
|
+
digest: str
|
|
32
|
+
"""The SHA-256 digest of the secret, in hex."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _digest(secret: str) -> str:
|
|
36
|
+
return hashlib.sha256(bytes.fromhex(secret)).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def issue(tenant: TenantId, principal: ServicePrincipal) -> tuple[str, Key]:
|
|
40
|
+
"""A new key for a principal: the key to show once, and what to store of it."""
|
|
41
|
+
key_id, secret = secrets.token_hex(8), secrets.token_hex(32)
|
|
42
|
+
stored = Key(id=key_id, tenant=tenant, principal=principal, digest=_digest(secret))
|
|
43
|
+
return f"grantr_sk_{key_id}_{secret}", stored
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def key_id(key: str) -> str | None:
|
|
47
|
+
"""A key's public id, or None if it isn't a key."""
|
|
48
|
+
match = KEY.fullmatch(key)
|
|
49
|
+
return match[1] if match else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify(key: str, stored: Key) -> bool:
|
|
53
|
+
"""Whether a key is the one stored: its id and its secret's digest match."""
|
|
54
|
+
match = KEY.fullmatch(key)
|
|
55
|
+
return (
|
|
56
|
+
match is not None
|
|
57
|
+
and match[1] == stored.id
|
|
58
|
+
and hmac.compare_digest(_digest(match[2]), stored.digest)
|
|
59
|
+
)
|