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 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
+ ]
@@ -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
+ )