ocmo-api 0.8.19__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.
Files changed (203) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. core/__init__.py +0 -0
  4. core/api/__init__.py +201 -0
  5. core/api/_common.py +37 -0
  6. core/api/audit.py +244 -0
  7. core/api/auth.py +50 -0
  8. core/api/config.py +96 -0
  9. core/api/errors.py +65 -0
  10. core/api/global_permissions.py +107 -0
  11. core/api/lock.py +99 -0
  12. core/api/namespace.py +86 -0
  13. core/api/propagation.py +29 -0
  14. core/api/resolve.py +229 -0
  15. core/api/resolver.py +96 -0
  16. core/api/secret.py +53 -0
  17. core/api/system.py +42 -0
  18. core/api/template.py +53 -0
  19. core/api/tree.py +274 -0
  20. core/apps.py +18 -0
  21. core/audit_operations.py +0 -0
  22. core/constants/__init__.py +5 -0
  23. core/constants/audit_operations.py +35 -0
  24. core/constants/permission_actions.py +59 -0
  25. core/constants/permissions.py +41 -0
  26. core/constants/resolve.py +3 -0
  27. core/constants/transformers.py +22 -0
  28. core/constants/webhook_events.py +31 -0
  29. core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
  30. core/data/builtin_schemas/_permissions.schema.yaml +211 -0
  31. core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
  32. core/decorators/__init__.py +14 -0
  33. core/decorators/audit.py +105 -0
  34. core/decorators/binding.py +49 -0
  35. core/decorators/permissions.py +129 -0
  36. core/decorators/webhook.py +87 -0
  37. core/exceptions.py +165 -0
  38. core/management/__init__.py +0 -0
  39. core/management/commands/__init__.py +0 -0
  40. core/management/commands/dump_openapi.py +40 -0
  41. core/managers/artifacts.py +346 -0
  42. core/managers/audit/__init__.py +1172 -0
  43. core/managers/audit/timeline.py +365 -0
  44. core/managers/auth.py +276 -0
  45. core/managers/cast.py +506 -0
  46. core/managers/config_validation.py +216 -0
  47. core/managers/crypto.py +78 -0
  48. core/managers/global_permissions.py +183 -0
  49. core/managers/health.py +56 -0
  50. core/managers/lock.py +229 -0
  51. core/managers/namespace.py +132 -0
  52. core/managers/permissions.py +168 -0
  53. core/managers/propagation.py +244 -0
  54. core/managers/resolution.py +938 -0
  55. core/managers/resolve_cache.py +266 -0
  56. core/managers/resolve_parameters.py +503 -0
  57. core/managers/resolver.py +56 -0
  58. core/managers/resolver_tokens.py +114 -0
  59. core/managers/resolving.py +673 -0
  60. core/managers/secret.py +147 -0
  61. core/managers/system.py +44 -0
  62. core/managers/tree/__init__.py +85 -0
  63. core/managers/tree/_common.py +85 -0
  64. core/managers/tree/base.py +203 -0
  65. core/managers/tree/config_ops.py +167 -0
  66. core/managers/tree/constants.py +11 -0
  67. core/managers/tree/create.py +75 -0
  68. core/managers/tree/diff.py +270 -0
  69. core/managers/tree/mutate.py +433 -0
  70. core/managers/tree/paths.py +49 -0
  71. core/managers/tree/propagation.py +200 -0
  72. core/managers/tree/read.py +263 -0
  73. core/managers/tree/resolver_ops.py +21 -0
  74. core/managers/tree/tags.py +167 -0
  75. core/managers/tree/update.py +137 -0
  76. core/managers/tree/versions.py +87 -0
  77. core/managers/tree_capabilities.py +189 -0
  78. core/managers/webhook/__init__.py +3 -0
  79. core/managers/webhook/cache.py +68 -0
  80. core/managers/webhook/main.py +357 -0
  81. core/middleware.py +32 -0
  82. core/migrations/0001_initial.py +157 -0
  83. core/migrations/0002_folder.py +33 -0
  84. core/migrations/0003_alter_namespace_name.py +17 -0
  85. core/migrations/0004_alter_namespace_name_and_more.py +25 -0
  86. core/migrations/0005_configversion_templateversion.py +85 -0
  87. core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
  88. core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
  89. core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
  90. core/migrations/0009_tree_lock.py +58 -0
  91. core/migrations/0010_resolver_token_encryption.py +75 -0
  92. core/migrations/0011_audit_event.py +134 -0
  93. core/migrations/0012_audit_event_namespace_name.py +24 -0
  94. core/migrations/0013_audit_event_object_id.py +28 -0
  95. core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
  96. core/migrations/0015_audit_event_operation_subresource.py +27 -0
  97. core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
  98. core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
  99. core/migrations/__init__.py +0 -0
  100. core/models/__init__.py +9 -0
  101. core/models/audit.py +97 -0
  102. core/models/config.py +132 -0
  103. core/models/folder.py +12 -0
  104. core/models/global_permissions.py +27 -0
  105. core/models/lock.py +35 -0
  106. core/models/namespace.py +45 -0
  107. core/models/resolver.py +25 -0
  108. core/models/secret.py +58 -0
  109. core/models/tree.py +29 -0
  110. core/notice.py +18 -0
  111. core/operation_ids.py +153 -0
  112. core/schemas/__init__.py +2 -0
  113. core/schemas/audit.py +75 -0
  114. core/schemas/cast_json_schema.py +57 -0
  115. core/schemas/cast_options.py +508 -0
  116. core/schemas/generic.py +233 -0
  117. core/schemas/metadata_json_schema.py +21 -0
  118. core/schemas/propagation.py +88 -0
  119. core/schemas/requests.py +773 -0
  120. core/schemas/resolver_json_schema.py +65 -0
  121. core/schemas/responses.py +690 -0
  122. core/schemas/webhooks.py +40 -0
  123. core/shortcuts.py +415 -0
  124. core/tests/__init__.py +0 -0
  125. core/tests/auth_helpers.py +23 -0
  126. core/tests/namespace_helpers.py +29 -0
  127. core/tests/test_api_cli.py +43 -0
  128. core/tests/test_api_errors.py +151 -0
  129. core/tests/test_artifacts.py +123 -0
  130. core/tests/test_audit.py +973 -0
  131. core/tests/test_audit_event_id.py +90 -0
  132. core/tests/test_audit_operations.py +222 -0
  133. core/tests/test_builtin_config_schemas.py +174 -0
  134. core/tests/test_can_i.py +510 -0
  135. core/tests/test_cast_format_options.py +57 -0
  136. core/tests/test_cast_options_schema.py +54 -0
  137. core/tests/test_cast_yaml.py +26 -0
  138. core/tests/test_config_schema_api.py +173 -0
  139. core/tests/test_config_validation.py +378 -0
  140. core/tests/test_document_parser.py +122 -0
  141. core/tests/test_dump_openapi.py +46 -0
  142. core/tests/test_extend_key.py +267 -0
  143. core/tests/test_folder_get_item.py +23 -0
  144. core/tests/test_folder_resolve_missing_tags.py +73 -0
  145. core/tests/test_get_item_version.py +31 -0
  146. core/tests/test_global_permission_create.py +171 -0
  147. core/tests/test_list_pagination.py +262 -0
  148. core/tests/test_locks.py +209 -0
  149. core/tests/test_multiline_transformer.py +132 -0
  150. core/tests/test_namespace_create.py +11 -0
  151. core/tests/test_ocmo_metadata.py +127 -0
  152. core/tests/test_oidc_jwt_audiences.py +64 -0
  153. core/tests/test_omit_transformer.py +78 -0
  154. core/tests/test_operation_ids.py +47 -0
  155. core/tests/test_permissions.py +1148 -0
  156. core/tests/test_propagation.py +441 -0
  157. core/tests/test_public_base_url.py +20 -0
  158. core/tests/test_resolve_draft.py +176 -0
  159. core/tests/test_resolve_parameters_version.py +181 -0
  160. core/tests/test_resolve_permissions.py +492 -0
  161. core/tests/test_resolve_resolver_response.py +107 -0
  162. core/tests/test_resolver_auth.py +33 -0
  163. core/tests/test_resolver_folder_filters.py +95 -0
  164. core/tests/test_resolver_get_item.py +23 -0
  165. core/tests/test_resolver_navigate_search_scope.py +98 -0
  166. core/tests/test_resolver_schema_api.py +91 -0
  167. core/tests/test_security_hardening.py +324 -0
  168. core/tests/test_selectors.py +65 -0
  169. core/tests/test_special_namespace_paths.py +409 -0
  170. core/tests/test_stable_promotion.py +124 -0
  171. core/tests/test_system.py +140 -0
  172. core/tests/test_tree_manager_for_item.py +192 -0
  173. core/tests/test_two_layer_cache.py +334 -0
  174. core/tests/test_url_trailing_slashes.py +53 -0
  175. core/tests/test_validation_error_responses.py +43 -0
  176. core/tests/test_webhooks.py +985 -0
  177. core/utils/__init__.py +23 -0
  178. core/utils/deep_merge.py +91 -0
  179. core/utils/namespace_special_configs.py +105 -0
  180. core/utils/permissions_compiler.py +627 -0
  181. core/utils/permissions_schema_document.py +52 -0
  182. core/validation_errors.py +105 -0
  183. core/version.py +27 -0
  184. core/views.py +1 -0
  185. ocmo_api-0.8.19.dist-info/METADATA +252 -0
  186. ocmo_api-0.8.19.dist-info/RECORD +203 -0
  187. ocmo_api-0.8.19.dist-info/WHEEL +4 -0
  188. ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
  189. ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
  190. ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
  191. ocmoapi/__init__.py +1 -0
  192. ocmoapi/__main__.py +6 -0
  193. ocmoapi/asgi.py +16 -0
  194. ocmoapi/auth.py +163 -0
  195. ocmoapi/cli.py +85 -0
  196. ocmoapi/oauth2_provider.py +23 -0
  197. ocmoapi/parser.py +120 -0
  198. ocmoapi/settings.py +275 -0
  199. ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
  200. ocmoapi/templates/ninja/swagger.html +55 -0
  201. ocmoapi/testing_auth.py +64 -0
  202. ocmoapi/urls.py +40 -0
  203. ocmoapi/wsgi.py +16 -0
@@ -0,0 +1,211 @@
1
+ _ocmo:
2
+ is_json_schema: true
3
+ $schema: https://json-schema.org/draft/2020-12/schema
4
+ title: Namespace permissions policy document
5
+ description: ABAC policies evaluated on every namespace operation. Policies are matched top-to-bottom;
6
+ explicit Deny wins over Allow.
7
+ type: object
8
+ additionalProperties: false
9
+ required:
10
+ - policies
11
+ properties:
12
+ policies:
13
+ type: array
14
+ description: Ordered list of allow/deny policies.
15
+ items:
16
+ type: object
17
+ additionalProperties: false
18
+ required:
19
+ - effect
20
+ - actors
21
+ - actions
22
+ - resources
23
+ properties:
24
+ id:
25
+ type: string
26
+ minLength: 1
27
+ maxLength: 200
28
+ description: Optional stable identifier for audits and documentation.
29
+ examples:
30
+ - allow-dev-readers
31
+ description:
32
+ type: string
33
+ maxLength: 500
34
+ description: Human-readable summary of what this policy grants or denies.
35
+ examples:
36
+ - Allow developers read access to project/dev/**
37
+ effect:
38
+ type: string
39
+ enum:
40
+ - Allow
41
+ - Deny
42
+ description: Whether matching requests are allowed or denied.
43
+ examples:
44
+ - Allow
45
+ actors:
46
+ type: array
47
+ minItems: 1
48
+ description: Who the policy applies to. User actors match OIDC claims; Resolver actors match
49
+ resolver identities by tree path.
50
+ items:
51
+ oneOf:
52
+ - type: object
53
+ additionalProperties: false
54
+ required:
55
+ - kind
56
+ - claims
57
+ properties:
58
+ kind:
59
+ const: User
60
+ description: Match authenticated human users.
61
+ claims:
62
+ type: object
63
+ minProperties: 1
64
+ description: OIDC claim names mapped to literal values or ``*``. Use ``{!user.<claim>}``
65
+ only in resource globs, not here.
66
+ additionalProperties:
67
+ type: string
68
+ minLength: 1
69
+ pattern: ^(\*|\{!user\.[a-zA-Z0-9_]+\}|(?!.*\{!).+)$
70
+ description: Claim value, wildcard ``*``, or interpolation placeholder.
71
+ examples:
72
+ - alice@example.com
73
+ - '*'
74
+ examples:
75
+ - email: alice@example.com
76
+ - type: object
77
+ additionalProperties: false
78
+ required:
79
+ - kind
80
+ - path
81
+ properties:
82
+ kind:
83
+ const: Resolver
84
+ description: Match a resolver identity by its tree path.
85
+ path:
86
+ type: string
87
+ minLength: 1
88
+ maxLength: 512
89
+ format: uri-reference
90
+ x-ocmo-uri-reference: resolver
91
+ description: Resolver tree path. Use ``*`` to match any resolver. Relative paths resolve
92
+ from the ``_permissions`` folder.
93
+ examples:
94
+ - deploy/prod
95
+ - '*'
96
+ actions:
97
+ type: array
98
+ minItems: 1
99
+ description: Permission operations this policy governs. Use ``resource:verb`` pairs; ``*`` wildcards
100
+ match any resource or verb.
101
+ items:
102
+ type: string
103
+ pattern: ^(config|template|resolver|secret|lock|folder|\*):(read|write|delete|resolve|tag|describe|audit|\*)$
104
+ enum:
105
+ - config:read
106
+ - config:write
107
+ - config:delete
108
+ - config:resolve
109
+ - config:tag
110
+ - config:describe
111
+ - config:audit
112
+ - template:read
113
+ - template:write
114
+ - template:delete
115
+ - template:tag
116
+ - template:describe
117
+ - template:audit
118
+ - resolver:read
119
+ - resolver:write
120
+ - resolver:delete
121
+ - resolver:audit
122
+ - secret:read
123
+ - secret:write
124
+ - secret:delete
125
+ - secret:resolve
126
+ - secret:tag
127
+ - secret:describe
128
+ - secret:audit
129
+ - lock:read
130
+ - lock:write
131
+ - lock:delete
132
+ - folder:describe
133
+ - folder:audit
134
+ - config:*
135
+ - template:*
136
+ - resolver:*
137
+ - secret:*
138
+ - lock:*
139
+ - '*:read'
140
+ - '*:write'
141
+ - '*:delete'
142
+ - '*:resolve'
143
+ - '*:tag'
144
+ - '*:describe'
145
+ - '*:audit'
146
+ - '*:*'
147
+ description: Concrete ``<resource>:<verb>`` pair or glob such as ``config:*``, ``*:read``,
148
+ or ``*:*``.
149
+ examples:
150
+ - config:read
151
+ - config:*
152
+ - '*:read'
153
+ - '*:*'
154
+ examples:
155
+ - - config:read
156
+ - config:describe
157
+ resources:
158
+ type: array
159
+ minItems: 1
160
+ description: Resource path globs the policy applies to. Supports ``*`` and ``**`` fnmatch patterns
161
+ and ``{!user.claim}`` interpolation.
162
+ items:
163
+ type: string
164
+ minLength: 1
165
+ maxLength: 512
166
+ format: uri-reference
167
+ x-ocmo-uri-reference: resource
168
+ description: Tree path or glob (e.g. ``project/dev/**``, ``secrets/**``). Folder paths may
169
+ end with ``/**`` to match all descendants under the folder.
170
+ examples:
171
+ - project/dev/**
172
+ - app/prod/**
173
+ - '**'
174
+ examples:
175
+ - - project/dev/**
176
+ conditions:
177
+ type: object
178
+ additionalProperties: false
179
+ description: Optional constraints that must all match for the policy to apply.
180
+ properties:
181
+ ip_range:
182
+ type: array
183
+ minItems: 1
184
+ description: Client IP ranges (IPv4/IPv6 CIDR). Denied when IP is unknown.
185
+ items:
186
+ type: string
187
+ pattern: ^(?:(?:\d{1,3}\.){3}\d{1,3}(?:\/(?:\d|[1-2]\d|3[0-2]))?|[0-9a-fA-F:]+(?:\/\d{1,3})?)$
188
+ description: IPv4 or IPv6 network in CIDR notation.
189
+ examples:
190
+ - 10.0.0.0/8
191
+ - 2001:db8::/32
192
+ time_of_day:
193
+ type: array
194
+ minItems: 1
195
+ description: UTC time windows when the policy is active (half-open intervals).
196
+ items:
197
+ type: string
198
+ pattern: ^(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d$
199
+ description: UTC time range ``HH:MM-HH:MM``.
200
+ examples:
201
+ - 09:00-17:30
202
+ examples:
203
+ - - effect: Allow
204
+ actors:
205
+ - kind: User
206
+ claims:
207
+ email: '*'
208
+ actions:
209
+ - config:read
210
+ resources:
211
+ - project/**
@@ -0,0 +1,145 @@
1
+ _ocmo:
2
+ is_json_schema: true
3
+ $schema: https://json-schema.org/draft/2020-12/schema
4
+ title: Namespace webhook delivery configuration
5
+ description: Outbound HTTP notifications for namespace events. Each entry is evaluated independently;
6
+ payloads are HMAC-signed.
7
+ type: object
8
+ additionalProperties: false
9
+ required:
10
+ - webhooks
11
+ properties:
12
+ webhooks:
13
+ type: array
14
+ description: Webhook delivery entries.
15
+ items:
16
+ type: object
17
+ additionalProperties: false
18
+ required:
19
+ - id
20
+ - enabled
21
+ - url
22
+ - events
23
+ - signature_key
24
+ properties:
25
+ id:
26
+ type: string
27
+ minLength: 1
28
+ maxLength: 100
29
+ pattern: ^[a-zA-Z][a-zA-Z0-9_-]*$
30
+ description: Stable identifier unique within this config.
31
+ examples:
32
+ - prod-notify
33
+ enabled:
34
+ type: boolean
35
+ description: When false, the entry is skipped without removing it.
36
+ examples:
37
+ - true
38
+ url:
39
+ type: string
40
+ minLength: 1
41
+ maxLength: 2048
42
+ format: uri
43
+ description: HTTPS endpoint that receives POST requests.
44
+ examples:
45
+ - https://hooks.example.com/ocmo
46
+ events:
47
+ type: array
48
+ minItems: 1
49
+ description: Event names that trigger delivery for this entry.
50
+ items:
51
+ type: string
52
+ enum:
53
+ - config.created
54
+ - config.updated
55
+ - config.deleted
56
+ - config.tagged
57
+ - config.resolved
58
+ - resolver.created
59
+ - resolver.updated
60
+ - secret.created
61
+ - secret.updated
62
+ - secret.deleted
63
+ - secret.tagged
64
+ - secret.resolved
65
+ - namespace.updated
66
+ - lock.created
67
+ - lock.updated
68
+ - lock.deleted
69
+ - propagation.triggered
70
+ description: Namespace event identifier.
71
+ examples:
72
+ - config.updated
73
+ examples:
74
+ - - config.updated
75
+ - config.tagged
76
+ filter:
77
+ type: object
78
+ additionalProperties: false
79
+ description: Optional narrowing of which item paths fire this webhook.
80
+ properties:
81
+ paths:
82
+ type: array
83
+ minItems: 1
84
+ description: Fnmatch globs matched against the event item path. Omit the filter block to
85
+ receive all subscribed events.
86
+ items:
87
+ type: string
88
+ minLength: 1
89
+ maxLength: 512
90
+ format: uri-reference
91
+ x-ocmo-uri-reference: resource
92
+ description: Tree path glob (e.g. ``project/**``).
93
+ examples:
94
+ - project/**
95
+ - app/prod/**
96
+ signature_header:
97
+ type: string
98
+ minLength: 1
99
+ maxLength: 100
100
+ description: HTTP header carrying the HMAC-SHA256 hex digest.
101
+ default: X-OCMO-Signature
102
+ examples:
103
+ - X-OCMO-Signature
104
+ signature_key:
105
+ type: string
106
+ minLength: 1
107
+ maxLength: 512
108
+ description: HMAC signing key literal or ``{!param}`` reference resolved from ``_ocmo.parameters``.
109
+ examples:
110
+ - '{!hmac_signing_key}'
111
+ payload:
112
+ type: object
113
+ additionalProperties: true
114
+ description: Payload shape controls. Set ``preset`` or ``template`` (mutually exclusive).
115
+ properties:
116
+ preset:
117
+ type: string
118
+ enum:
119
+ - ocmo
120
+ - generic_json
121
+ - slack
122
+ - teams
123
+ - discord
124
+ description: Built-in payload formatter.
125
+ examples:
126
+ - ocmo
127
+ template:
128
+ type: string
129
+ minLength: 1
130
+ description: Jinja2 template rendered to the request body.
131
+ headers:
132
+ type: object
133
+ description: Extra HTTP headers appended to each delivery.
134
+ additionalProperties:
135
+ type: string
136
+ minLength: 1
137
+ examples:
138
+ - - id: example
139
+ enabled: false
140
+ url: https://example.com/ocmo/webhook
141
+ events:
142
+ - config.updated
143
+ signature_key: '{!hmac_signing_key}'
144
+ payload:
145
+ preset: ocmo
@@ -0,0 +1,14 @@
1
+ """Cross-cutting manager method decorators."""
2
+
3
+ from .audit import audit, enrich_audit
4
+ from .permissions import PermCheck, arg, require_permissions
5
+ from .webhook import webhook
6
+
7
+ __all__ = [
8
+ "audit",
9
+ "enrich_audit",
10
+ "arg",
11
+ "PermCheck",
12
+ "require_permissions",
13
+ "webhook",
14
+ ]
@@ -0,0 +1,105 @@
1
+ """@audit decorator for append-only operation logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class AuditEventContext:
14
+ """Public post-call fields a method may supply to the active audit event."""
15
+
16
+ operation: str | None = None
17
+ object_version: int | None = None
18
+ subresource_type: str | None = None
19
+ subresource: str | None = None
20
+
21
+
22
+ _audit_operation_context: ContextVar[AuditEventContext | None] = ContextVar(
23
+ "audit_operation_context",
24
+ default=None,
25
+ )
26
+
27
+
28
+ def enrich_audit(**kwargs) -> None:
29
+ """Set post-call audit fields from inside a decorated method body.
30
+
31
+ Safe to call when no audit is active (no-op).
32
+ Body values take precedence over decorator defaults.
33
+ """
34
+ ctx = _audit_operation_context.get()
35
+ if ctx is None:
36
+ return
37
+ for key, value in kwargs.items():
38
+ if value is not None and hasattr(ctx, key):
39
+ setattr(ctx, key, value)
40
+
41
+
42
+ def _take_audit_context() -> AuditEventContext | None:
43
+ """Consume and clear the ContextVar; called by run_decorated after func()."""
44
+ ctx = _audit_operation_context.get()
45
+ _audit_operation_context.set(None)
46
+ return ctx
47
+
48
+
49
+ def audit(
50
+ object_type: str | Callable,
51
+ *,
52
+ object_id_attr: str | Callable | None = None,
53
+ namespace_attribute: str = "namespace",
54
+ resolve_type: str | None = None,
55
+ operation: str | Callable | None = None,
56
+ subresource_type: str | Callable | None = None,
57
+ subresource: str | Callable | None = None,
58
+ object_version: int | str | Callable | None = None,
59
+ skip_when: Callable | None = None,
60
+ skip_when_no_auth: bool = True,
61
+ auth_manager_attribute: str = "auth",
62
+ ) -> Callable:
63
+ """Decorator for manager public methods — logs outcome after execution."""
64
+
65
+ def decorator(func: Callable) -> Callable:
66
+ try:
67
+ sig = inspect.signature(func)
68
+ func_params = [
69
+ name
70
+ for name, p in sig.parameters.items()
71
+ if name != "self" and p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)
72
+ ]
73
+ except (ValueError, TypeError):
74
+ func_params = []
75
+
76
+ @functools.wraps(func)
77
+ def wrapper(self, *args, **kwargs):
78
+ from ..managers.audit import AuditManager
79
+
80
+ audit_mgr = AuditManager.for_decorated_call(
81
+ self,
82
+ auth_manager_attribute=auth_manager_attribute,
83
+ skip_when_no_auth=skip_when_no_auth,
84
+ )
85
+ if audit_mgr is None:
86
+ return func(self, *args, **kwargs)
87
+ return audit_mgr.run_decorated(
88
+ func,
89
+ self,
90
+ args,
91
+ kwargs,
92
+ object_type=object_type,
93
+ object_id_attr=object_id_attr,
94
+ skip_when=skip_when,
95
+ func_params=func_params,
96
+ resolve_type=resolve_type,
97
+ operation=operation,
98
+ subresource_type=subresource_type,
99
+ subresource=subresource,
100
+ object_version=object_version,
101
+ )
102
+
103
+ return wrapper
104
+
105
+ return decorator
@@ -0,0 +1,49 @@
1
+ """Shared argument-binding helpers for manager method decorators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+
11
+ def bind_args(param_names: list[str], args: tuple, kwargs: dict) -> dict[str, Any]:
12
+ """Map positional + keyword call arguments to a name→value dict."""
13
+ bound: dict[str, Any] = {}
14
+ for i, name in enumerate(param_names):
15
+ if i < len(args):
16
+ bound[name] = args[i]
17
+ bound.update(kwargs)
18
+ return bound
19
+
20
+
21
+ @functools.lru_cache(maxsize=512)
22
+ def _callable_extra_params(fn: Callable) -> tuple[str, ...]:
23
+ """Return declared parameter names beyond 'self' (cached per callable)."""
24
+ try:
25
+ sig = inspect.signature(fn)
26
+ params = list(sig.parameters.values())
27
+ return tuple(p.name for p in params[1:] if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD))
28
+ except (ValueError, TypeError):
29
+ return ()
30
+
31
+
32
+ def invoke(fn: Callable, instance, bound_args: dict[str, Any]):
33
+ """Call fn(instance) or fn(instance, **matching_bound_args) from its signature."""
34
+ extra = _callable_extra_params(fn)
35
+ if not extra:
36
+ return fn(instance)
37
+ kwargs = {name: bound_args[name] for name in extra if name in bound_args}
38
+ return fn(instance, **kwargs)
39
+
40
+
41
+ def resolve_resource(instance, resource, bound_args: dict[str, Any]) -> str:
42
+ """Resolve a resource specification to a string."""
43
+ if resource is None:
44
+ return getattr(instance, "path", "")
45
+ if callable(resource):
46
+ if getattr(resource, "_is_arg_extractor", False):
47
+ return resource(instance, bound_args)
48
+ return invoke(resource, instance, bound_args)
49
+ return getattr(instance, resource, "")
@@ -0,0 +1,129 @@
1
+ """@require_permissions decorator and PermCheck configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from ..exceptions import CapabilityDenied, NotFound, PermissionDenied
13
+ from .binding import bind_args, invoke, resolve_resource
14
+
15
+ _NAMESPACE_PREFIX = "namespace"
16
+ _GLOBAL_PREFIX = "global"
17
+
18
+
19
+ @dataclass
20
+ class PermCheck:
21
+ """One permission check: action on a resource with optional error masking."""
22
+
23
+ action: str | Callable
24
+ resource: str | Callable | None = None
25
+ mask_as_not_found: bool = False
26
+ not_found_message: str | Callable | None = None
27
+
28
+
29
+ def arg(name: str, transform: Callable | None = None) -> Callable:
30
+ """Create a resource callable that extracts a named method argument."""
31
+
32
+ def _extractor(instance, bound_args: dict) -> str:
33
+ value = bound_args.get(name, "")
34
+ return transform(value) if transform else value
35
+
36
+ _extractor._is_arg_extractor = True # type: ignore[attr-defined]
37
+ return _extractor
38
+
39
+
40
+ def _coerce_check(check: PermCheck | str | Callable) -> PermCheck:
41
+ if isinstance(check, PermCheck):
42
+ return check
43
+ return PermCheck(action=check)
44
+
45
+
46
+ def _apply_check(
47
+ check: PermCheck,
48
+ instance,
49
+ bound_args: dict,
50
+ auth,
51
+ pm,
52
+ ) -> None:
53
+ try:
54
+ resolved = invoke(check.action, instance, bound_args) if callable(check.action) else check.action
55
+ if resolved is None:
56
+ return
57
+
58
+ if isinstance(resolved, list):
59
+ pairs = resolved
60
+ else:
61
+ resource = resolve_resource(instance, check.resource, bound_args)
62
+ pairs = [(resolved, resource)]
63
+
64
+ for action, resource in pairs:
65
+ type_, _, verb = action.partition(":")
66
+
67
+ if type_ == _GLOBAL_PREFIX and verb == "admin":
68
+ if not auth.is_global_admin:
69
+ raise PermissionDenied("Global administrator access required")
70
+
71
+ elif type_ == _NAMESPACE_PREFIX:
72
+ pm.require_namespace_object(resource, verb)
73
+
74
+ else:
75
+ request_ctx: dict[str, Any] = {"time": datetime.now(UTC)}
76
+ pm.require_tree(action, resource, request_ctx)
77
+
78
+ except PermissionDenied as exc:
79
+ if check.mask_as_not_found:
80
+ if check.not_found_message is None:
81
+ msg = str(exc)
82
+ elif callable(check.not_found_message):
83
+ msg = check.not_found_message(instance)
84
+ else:
85
+ msg = check.not_found_message
86
+ raise NotFound(msg) from exc
87
+ raise
88
+
89
+
90
+ def require_permissions(
91
+ *checks: PermCheck | str | Callable,
92
+ auth_manager_attribute: str = "auth",
93
+ namespace_attribute: str = "namespace",
94
+ ) -> Callable:
95
+ """Decorator for manager public methods."""
96
+
97
+ def decorator(func: Callable) -> Callable:
98
+ try:
99
+ sig = inspect.signature(func)
100
+ func_params = [
101
+ name
102
+ for name, p in sig.parameters.items()
103
+ if name != "self" and p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)
104
+ ]
105
+ except (ValueError, TypeError):
106
+ func_params = []
107
+
108
+ @functools.wraps(func)
109
+ def wrapper(self, *args, **kwargs):
110
+ auth = getattr(self, auth_manager_attribute, None)
111
+
112
+ if auth is None:
113
+ return func(self, *args, **kwargs)
114
+
115
+ bound_args = bind_args(func_params, args, kwargs)
116
+ namespace = getattr(self, namespace_attribute, None)
117
+ pm = auth.permissions(namespace)
118
+
119
+ for check in checks:
120
+ _apply_check(_coerce_check(check), self, bound_args, auth, pm)
121
+
122
+ try:
123
+ return func(self, *args, **kwargs)
124
+ except CapabilityDenied as exc:
125
+ raise PermissionDenied(str(exc)) from exc
126
+
127
+ return wrapper
128
+
129
+ return decorator