memgres 0.10.0__tar.gz → 0.12.0__tar.gz
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.
- {memgres-0.10.0 → memgres-0.12.0}/PKG-INFO +2 -1
- {memgres-0.10.0 → memgres-0.12.0}/README.md +1 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/__init__.py +2 -1
- {memgres-0.10.0 → memgres-0.12.0}/memgres/_version.py +1 -1
- {memgres-0.10.0 → memgres-0.12.0}/memgres/admin.py +82 -8
- {memgres-0.10.0 → memgres-0.12.0}/memgres/bootstrap.py +10 -6
- {memgres-0.10.0 → memgres-0.12.0}/memgres/config.py +6 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/identity.py +199 -32
- {memgres-0.10.0 → memgres-0.12.0}/memgres/info.py +8 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/links.py +63 -4
- {memgres-0.10.0 → memgres-0.12.0}/memgres/mcp_server.py +36 -16
- memgres-0.12.0/memgres/migrations/0023_relink_after_parser_fix.sql +16 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/schema.py +5 -1
- {memgres-0.10.0 → memgres-0.12.0}/memgres/server.py +18 -6
- {memgres-0.10.0 → memgres-0.12.0}/memgres/store.py +69 -2
- {memgres-0.10.0 → memgres-0.12.0}/memgres/token_cli.py +22 -12
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/PKG-INFO +2 -1
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/SOURCES.txt +3 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_admin_two_way.py +15 -2
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_links.py +43 -1
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_list.py +1 -1
- memgres-0.12.0/tests/test_required_fields.py +195 -0
- memgres-0.12.0/tests/test_security_followups.py +353 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_security_integration.py +7 -3
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_server_info.py +20 -1
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_server_integration.py +6 -2
- {memgres-0.10.0 → memgres-0.12.0}/LICENSE +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/admin_cli.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/blame.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/delimiters.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/diffing.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/embed_worker.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/embeddings.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/healthcheck.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/indexing.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/lines.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/migrations/0022_user_disabled.sql +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/periodic.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/reembed.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/relink.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/search.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/segments.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/tags.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/base.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres/worker.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/pyproject.toml +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/setup.cfg +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_config.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_diffing.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_embeddings.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_enrollment.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_limits.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_multi_space_search.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_replace_build.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_require_title.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_retention.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_search_integration.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_segments.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_segments_store.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_snippets.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_store_integration.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_tags.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_token_sink.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_usage.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_valid_at.py +0 -0
- {memgres-0.10.0 → memgres-0.12.0}/tests/test_write_ergonomics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
|
|
5
5
|
Author: mozgsml
|
|
6
6
|
License-Expression: MIT
|
|
@@ -216,6 +216,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
216
216
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
217
217
|
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
218
218
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
219
|
+
| `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
|
|
219
220
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
220
221
|
| `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
|
|
221
222
|
| `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
|
|
@@ -180,6 +180,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
180
180
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
181
181
|
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
182
182
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
183
|
+
| `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
|
|
183
184
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
184
185
|
| `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
|
|
185
186
|
| `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
|
|
@@ -23,7 +23,7 @@ from .schema import migrate, SchemaMismatch, SCHEMA_VERSION
|
|
|
23
23
|
from .search import Hit, recall
|
|
24
24
|
from .blame import annotate, annotate_grouped, reconstruct, replay
|
|
25
25
|
from .store import (Store, Memory, Conflict, NotFound, TooLarge, NoParent,
|
|
26
|
-
MissingTitle)
|
|
26
|
+
MissingTitle, MissingField)
|
|
27
27
|
from .identity import (
|
|
28
28
|
Principal, AuthError, SpaceNotFound,
|
|
29
29
|
resolve, resolve_space, new_token, valid_format,
|
|
@@ -38,6 +38,7 @@ __all__ = [
|
|
|
38
38
|
"Config", "load_config",
|
|
39
39
|
"Store", "Memory", "Conflict", "NotFound", "TooLarge", "NoParent",
|
|
40
40
|
"MissingTitle",
|
|
41
|
+
"MissingField",
|
|
41
42
|
"make_diff", "apply_diff", "content_hash", "DiffConflict",
|
|
42
43
|
"Embedder", "get_embedder",
|
|
43
44
|
"migrate", "SchemaMismatch", "SCHEMA_VERSION",
|
|
@@ -362,7 +362,14 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
|
|
|
362
362
|
the instruction agents read to choose a namespace could not be fixed through
|
|
363
363
|
any door at all.
|
|
364
364
|
"""
|
|
365
|
-
|
|
365
|
+
# Description and instruction are administration; the NAME is closer to
|
|
366
|
+
# disposal — renaming out from under an owner breaks every by-name call they
|
|
367
|
+
# and every member make, and an admin member was given authority over the
|
|
368
|
+
# contents, not over how the namespace is addressed.
|
|
369
|
+
if name is not None:
|
|
370
|
+
require_namespace_owner(conn, p, namespace_id)
|
|
371
|
+
else:
|
|
372
|
+
require_namespace_admin(conn, p, namespace_id)
|
|
366
373
|
identity.edit_namespace(conn, namespace_id, description=description,
|
|
367
374
|
instruction=instruction, name=name)
|
|
368
375
|
out = {"namespace_id": namespace_id}
|
|
@@ -382,6 +389,11 @@ def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
|
|
|
382
389
|
matters here — which is why this is its own check rather than a flag.
|
|
383
390
|
"""
|
|
384
391
|
require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
|
|
392
|
+
# A namespace-pinned token is issued to WORK in that namespace; disposing of
|
|
393
|
+
# it is a step beyond what the pin implies, and the deployment-wide acts all
|
|
394
|
+
# demand an unscoped credential for the same reason.
|
|
395
|
+
if p.scope_namespace_id is not None:
|
|
396
|
+
raise Forbidden("this act needs an unscoped admin credential")
|
|
385
397
|
if p.is_admin:
|
|
386
398
|
return
|
|
387
399
|
if identity.namespace_owner(conn, namespace_id) != p.user_id:
|
|
@@ -505,7 +517,7 @@ def _unreachable_warning(conn, user_id: str,
|
|
|
505
517
|
def issue_token(conn, p: Principal, *, user_id: str,
|
|
506
518
|
namespace_id: Optional[str] = None, permission: str = "write",
|
|
507
519
|
label: str = "", expires_days: Optional[int] = None,
|
|
508
|
-
sink_dir: str = "") -> dict:
|
|
520
|
+
sink_dir: str = "", defer_delivery: bool = False) -> dict:
|
|
509
521
|
"""Mint a token for `user_id`. The secret is returned once and never again.
|
|
510
522
|
|
|
511
523
|
`expires_days` rather than a timestamp: both doors were converting the same
|
|
@@ -525,12 +537,39 @@ def issue_token(conn, p: Principal, *, user_id: str,
|
|
|
525
537
|
secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
|
|
526
538
|
permission=permission, label=label,
|
|
527
539
|
expires_at=expires_at)
|
|
540
|
+
if defer_delivery:
|
|
541
|
+
# The caller will commit and THEN write the file. Writing it inside the
|
|
542
|
+
# transaction leaves a 0600 file holding a live-looking secret for a
|
|
543
|
+
# token that a rollback removed — the CLI already delivers outside its
|
|
544
|
+
# transaction for exactly this reason, and the doors did not.
|
|
545
|
+
return {"secret": secret, "id": tid, "warning": warning}
|
|
528
546
|
out = deliver_secret(secret, tid, sink_dir)
|
|
529
547
|
if warning:
|
|
530
548
|
out["warning"] = warning
|
|
531
549
|
return out
|
|
532
550
|
|
|
533
551
|
|
|
552
|
+
def deliver_key(out: dict, sink_dir: str) -> dict:
|
|
553
|
+
"""Apply the deployment's delivery policy to a freshly minted ENROLLMENT KEY.
|
|
554
|
+
|
|
555
|
+
The 0.9.0 wave took the token out of the reply and left the key in it — but
|
|
556
|
+
a key is a bearer credential for as long as it lives, and whoever reads it
|
|
557
|
+
first gets the account it names. An operator who set a sink said "secrets do
|
|
558
|
+
not come back in replies", and meant this one too.
|
|
559
|
+
"""
|
|
560
|
+
if not sink_dir or "key" not in out:
|
|
561
|
+
return out
|
|
562
|
+
key = out.pop("key")
|
|
563
|
+
out["path"] = identity.stash_secret(sink_dir, out["id"], key,
|
|
564
|
+
suffix=".key")
|
|
565
|
+
out["delivered"] = "file"
|
|
566
|
+
out["exposed"] = False
|
|
567
|
+
out["note"] = ("the key was written to that file on the server and "
|
|
568
|
+
"deliberately NOT returned here — read it there and hand it "
|
|
569
|
+
"to its owner")
|
|
570
|
+
return out
|
|
571
|
+
|
|
572
|
+
|
|
534
573
|
def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
|
|
535
574
|
"""The reply for a freshly minted token: the secret itself, or — when the
|
|
536
575
|
deployment set a sink — only where it was put. One function so both minting
|
|
@@ -583,7 +622,11 @@ def list_enrollments(conn, p: Principal, *,
|
|
|
583
622
|
require_manage_users(p)
|
|
584
623
|
if user_id is not None:
|
|
585
624
|
_require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
|
|
586
|
-
|
|
625
|
+
return identity.list_enrollments(conn, user_id=user_id)
|
|
626
|
+
# Unfiltered, the listing handed a user_manager the credential timeline of
|
|
627
|
+
# the accounts ABOVE it — `list_tokens` refuses exactly that. The guard has
|
|
628
|
+
# to apply to the shape without a target too, or it is only a speed bump.
|
|
629
|
+
return identity.list_enrollments(conn, plain_users_only=not p.is_admin)
|
|
587
630
|
|
|
588
631
|
|
|
589
632
|
def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
|
|
@@ -692,11 +735,26 @@ def request_access(conn, p: Principal, *, namespace_id: str,
|
|
|
692
735
|
"""
|
|
693
736
|
if p.user_id is None:
|
|
694
737
|
raise identity.AuthError("this token has no owning user")
|
|
738
|
+
# You may not ask for more than this credential could exercise if granted.
|
|
739
|
+
# A read-only agent token filing an `admin` request is the input half of an
|
|
740
|
+
# over-grant: the approver sees a plausible ask and the account keeps the
|
|
741
|
+
# membership long after that token is gone.
|
|
742
|
+
permission = identity.perm_min(permission, p.permission)
|
|
695
743
|
perm = identity.reaches(conn, p.user_id, namespace_id)
|
|
696
744
|
if perm is not None:
|
|
697
745
|
return {"status": "already_reachable", "permission": perm}
|
|
698
746
|
identity.request_access(conn, p.user_id, namespace_id, permission)
|
|
699
|
-
|
|
747
|
+
# What is actually pending, which is not always what was asked: a request
|
|
748
|
+
# you already hold may be lowered but not raised, and a read-only credential
|
|
749
|
+
# cannot ask beyond its ceiling. Saying so beats a receipt that agrees with
|
|
750
|
+
# you and a grant that does not.
|
|
751
|
+
with conn.cursor() as cur:
|
|
752
|
+
cur.execute("SELECT requested_permission FROM access_request WHERE "
|
|
753
|
+
"requester_user_id=%s AND namespace_id=%s",
|
|
754
|
+
(p.user_id, identity._as_uuid(namespace_id)))
|
|
755
|
+
row = cur.fetchone()
|
|
756
|
+
return {"status": "submitted",
|
|
757
|
+
"permission": row[0] if row else permission}
|
|
700
758
|
|
|
701
759
|
|
|
702
760
|
def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
|
|
@@ -705,16 +763,32 @@ def list_requests(conn, p: Principal, *, namespace_id: str) -> List[dict]:
|
|
|
705
763
|
return identity.list_requests(conn, namespace_id)
|
|
706
764
|
|
|
707
765
|
|
|
708
|
-
def decide_access(conn, p: Principal, *, request_id: str, approve: bool
|
|
709
|
-
|
|
766
|
+
def decide_access(conn, p: Principal, *, request_id: str, approve: bool,
|
|
767
|
+
expect_permission: Optional[str] = None) -> None:
|
|
768
|
+
"""Approve or deny a request, authorized against the namespace it targets.
|
|
769
|
+
|
|
770
|
+
`expect_permission` is the permission the approver SAW in `list_requests`.
|
|
771
|
+
Pass it: without it, approval grants whatever the row says when it is read,
|
|
772
|
+
and a requester can raise their own pending ask between the listing and the
|
|
773
|
+
decision. `request_access` no longer amends a pending request, so this is
|
|
774
|
+
the second lock on the same door rather than the only one.
|
|
775
|
+
|
|
776
|
+
Both "no such request" and "a request you may not decide" answer the same
|
|
777
|
+
way, for the same reason `request_access` does: which uuids are real is not
|
|
778
|
+
something a refusal should confirm.
|
|
779
|
+
"""
|
|
710
780
|
with conn.cursor() as cur:
|
|
711
781
|
cur.execute("SELECT namespace_id FROM access_request WHERE id=%s",
|
|
712
782
|
(request_id,))
|
|
713
783
|
row = cur.fetchone()
|
|
714
784
|
if row is None:
|
|
715
785
|
raise identity.SpaceNotFound("no such request")
|
|
716
|
-
|
|
786
|
+
try:
|
|
787
|
+
require_namespace_admin(conn, p, str(row[0]))
|
|
788
|
+
except (Forbidden, identity.SpaceNotFound):
|
|
789
|
+
raise identity.SpaceNotFound("no such request") from None
|
|
717
790
|
if approve:
|
|
718
|
-
identity.approve_request(conn, request_id
|
|
791
|
+
identity.approve_request(conn, request_id,
|
|
792
|
+
expect_permission=expect_permission)
|
|
719
793
|
else:
|
|
720
794
|
identity.deny_request(conn, request_id)
|
|
@@ -44,22 +44,26 @@ def _read_or_create_token_file(path: str) -> Tuple[str, Optional[str]]:
|
|
|
44
44
|
"""Return ``(secret, generated_path)``. If the file has a token, read it
|
|
45
45
|
(``generated_path`` None). If missing/empty, generate a fresh ``mgk_`` token,
|
|
46
46
|
write it ``0600``, and return the path so the caller can log it."""
|
|
47
|
+
# O_NOFOLLOW on the READ as well: whoever can plant a symlink here would
|
|
48
|
+
# otherwise CHOOSE the deployment's bootstrap admin token by pointing this
|
|
49
|
+
# at a file they wrote. Reading through it is the more dangerous half.
|
|
47
50
|
try:
|
|
48
|
-
|
|
51
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
52
|
+
with os.fdopen(fd, "r", encoding="utf-8") as f:
|
|
49
53
|
existing = f.read().strip()
|
|
50
54
|
except FileNotFoundError:
|
|
51
55
|
existing = ""
|
|
52
56
|
except OSError as e: # unreadable → fail loud
|
|
53
|
-
raise BootstrapError(
|
|
57
|
+
raise BootstrapError(
|
|
58
|
+
f"cannot read MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}"
|
|
59
|
+
+ (" (a symlink is refused here on purpose)"
|
|
60
|
+
if os.path.islink(path) else ""))
|
|
54
61
|
if existing:
|
|
55
62
|
return existing, None
|
|
56
63
|
|
|
57
64
|
secret = identity.new_token()
|
|
58
65
|
try:
|
|
59
|
-
|
|
60
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
61
|
-
f.write(secret + "\n")
|
|
62
|
-
os.chmod(path, 0o600) # tighten even if the file pre-existed
|
|
66
|
+
identity.write_private(path, secret + "\n")
|
|
63
67
|
except OSError as e:
|
|
64
68
|
raise BootstrapError(f"cannot write MEMGRES_ADMIN_TOKEN_FILE {path!r}: {e}")
|
|
65
69
|
return secret, path
|
|
@@ -79,6 +79,8 @@ class Config:
|
|
|
79
79
|
# organization
|
|
80
80
|
tree_enabled: bool # ltree path column + GiST index for fast subtree selection
|
|
81
81
|
require_title: bool # True = a write that stores CONTENT must caption it
|
|
82
|
+
required_fields: tuple # extra fields a content-storing write must carry
|
|
83
|
+
# (MEMGRES_REQUIRED_FIELDS=source), refused when absent
|
|
82
84
|
require_parent: bool # False = sparse paths (create food.apple with no food row);
|
|
83
85
|
# True = a node's parent path must already exist as a memory
|
|
84
86
|
# history
|
|
@@ -222,6 +224,10 @@ def load() -> Config:
|
|
|
222
224
|
token_sink=_str("MEMGRES_TOKEN_SINK", ""),
|
|
223
225
|
tree_enabled=_bool("MEMGRES_TREE", True),
|
|
224
226
|
require_title=_bool("MEMGRES_REQUIRE_TITLE", True),
|
|
227
|
+
required_fields=tuple(
|
|
228
|
+
f for f in (x.strip().lower()
|
|
229
|
+
for x in _str("MEMGRES_REQUIRED_FIELDS", "").split(","))
|
|
230
|
+
if f),
|
|
225
231
|
require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
|
|
226
232
|
history_enabled=_bool("MEMGRES_HISTORY", True),
|
|
227
233
|
fts_language=_str("MEMGRES_FTS_LANGUAGE", "simple"),
|
|
@@ -27,12 +27,15 @@ from __future__ import annotations
|
|
|
27
27
|
import datetime as dt
|
|
28
28
|
import hashlib
|
|
29
29
|
import hmac
|
|
30
|
+
import logging
|
|
30
31
|
import os
|
|
31
32
|
import re
|
|
32
33
|
import secrets
|
|
33
34
|
from dataclasses import dataclass
|
|
34
35
|
from typing import List, Optional, Sequence, Tuple
|
|
35
36
|
|
|
37
|
+
logger = logging.getLogger("memgres.identity")
|
|
38
|
+
|
|
36
39
|
# ─── token format: mgk_ + 43 url-safe chars (256-bit) ────────────────────────
|
|
37
40
|
TOKEN_RE = re.compile(r"^mgk_[A-Za-z0-9_-]{43}$")
|
|
38
41
|
|
|
@@ -205,7 +208,22 @@ def resolve(conn, cfg, secret: Optional[str], *, touch: bool = True) -> Principa
|
|
|
205
208
|
# an account that is off has to be off for every token it
|
|
206
209
|
# holds and every one it could still be issued, and the only
|
|
207
210
|
# place all of those meet is authentication.
|
|
208
|
-
|
|
211
|
+
#
|
|
212
|
+
# One exception, and it is the recovery path: bootstrap
|
|
213
|
+
# stores the env secret AS A TOKEN of the seeded admin, so
|
|
214
|
+
# this lookup matches it first and disabling that account
|
|
215
|
+
# would take the operator's out-of-band way back in with it.
|
|
216
|
+
# Two ordinary control-plane calls could then leave a
|
|
217
|
+
# deployment nobody can enter. The env secret keeps working,
|
|
218
|
+
# as the anonymous root it is when no account holds it.
|
|
219
|
+
if not (cfg.admin_token
|
|
220
|
+
and hmac.compare_digest(secret, cfg.admin_token)):
|
|
221
|
+
raise AuthError("this account is disabled")
|
|
222
|
+
logger.warning(
|
|
223
|
+
"memgres: the bootstrap admin account is disabled; the "
|
|
224
|
+
"env break-glass token authenticated as anonymous root")
|
|
225
|
+
return Principal(user_id=None, permission="admin",
|
|
226
|
+
scope_namespace_id=None, is_admin=True)
|
|
209
227
|
if touch:
|
|
210
228
|
cur.execute("UPDATE token SET last_used_at=now() WHERE id=%s",
|
|
211
229
|
(tid,))
|
|
@@ -779,21 +797,43 @@ def list_users(conn, *, role: Optional[str] = None, limit: Optional[int] = None,
|
|
|
779
797
|
|
|
780
798
|
|
|
781
799
|
# ─── service roles (control plane; see SERVICE_ROLES) ────────────────────────
|
|
800
|
+
# Every anti-lockout count asks the same question — "is anyone left who can
|
|
801
|
+
# undo this?" — and a disabled account is not anyone. Two of these used to count
|
|
802
|
+
# disabled admins as present, which let the guards be walked around: disable the
|
|
803
|
+
# second superadmin (allowed, two are active), then DEMOTE the first through
|
|
804
|
+
# `revoke_superadmin`, which saw two superadmins and agreed. End state: zero
|
|
805
|
+
# active superadmins and recovery by hand-editing the database.
|
|
806
|
+
_ACTIVE_ADMIN = "role IN ('user_manager','superadmin') AND disabled_at IS NULL"
|
|
807
|
+
_ACTIVE_SUPERADMIN = "role='superadmin' AND disabled_at IS NULL"
|
|
808
|
+
|
|
809
|
+
|
|
782
810
|
def count_service_admins(conn) -> int:
|
|
783
|
-
"""How many users hold an admin role
|
|
784
|
-
|
|
811
|
+
"""How many users hold an admin role AND can still use it. Zero ⇒ nobody is
|
|
812
|
+
running the control plane — the trigger for bootstrap seeding, which is
|
|
813
|
+
exactly what should happen when the last admin has been switched off."""
|
|
785
814
|
with conn.cursor() as cur:
|
|
786
|
-
cur.execute("SELECT count(*) FROM app_user WHERE
|
|
787
|
-
"('user_manager','superadmin')")
|
|
815
|
+
cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_ADMIN}")
|
|
788
816
|
return int(cur.fetchone()[0])
|
|
789
817
|
|
|
790
818
|
|
|
791
819
|
def count_superadmins(conn) -> int:
|
|
820
|
+
"""Active superadmins. See `_ACTIVE_ADMIN` for why `disabled_at` matters."""
|
|
792
821
|
with conn.cursor() as cur:
|
|
793
|
-
cur.execute("SELECT count(*) FROM app_user WHERE
|
|
822
|
+
cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
|
|
794
823
|
return int(cur.fetchone()[0])
|
|
795
824
|
|
|
796
825
|
|
|
826
|
+
def _lock_admin_rows(cur) -> None:
|
|
827
|
+
"""Serialize the anti-lockout guards against each other.
|
|
828
|
+
|
|
829
|
+
Both `set_disabled` and `revoke_superadmin` read a count and then write.
|
|
830
|
+
Two sessions removing the last two superadmins each saw two, and both were
|
|
831
|
+
allowed. Locking the admin rows first makes the second one read the first
|
|
832
|
+
one's result instead of the state it started from.
|
|
833
|
+
"""
|
|
834
|
+
cur.execute(f"SELECT id FROM app_user WHERE {_ACTIVE_ADMIN} FOR UPDATE")
|
|
835
|
+
|
|
836
|
+
|
|
797
837
|
def get_role(conn, user_id: str) -> Optional[str]:
|
|
798
838
|
with conn.cursor() as cur:
|
|
799
839
|
cur.execute("SELECT role FROM app_user WHERE id=%s", (user_id,))
|
|
@@ -828,9 +868,10 @@ def revoke_superadmin(conn, user_id: str, *, demote_to: str = "user") -> None:
|
|
|
828
868
|
raise SpaceNotFound(f"no such user {user_id}")
|
|
829
869
|
if row[0] != "superadmin":
|
|
830
870
|
return # nothing to revoke
|
|
831
|
-
cur
|
|
871
|
+
_lock_admin_rows(cur)
|
|
872
|
+
cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
|
|
832
873
|
if int(cur.fetchone()[0]) <= 1:
|
|
833
|
-
raise AuthError("cannot revoke the last superadmin")
|
|
874
|
+
raise AuthError("cannot revoke the last active superadmin")
|
|
834
875
|
cur.execute("UPDATE app_user SET role=%s WHERE id=%s", (demote_to, user_id))
|
|
835
876
|
|
|
836
877
|
|
|
@@ -853,8 +894,8 @@ def set_disabled(conn, user_id: str, disabled: bool) -> dict:
|
|
|
853
894
|
return {"user_id": str(user_id), "disabled": bool(disabled),
|
|
854
895
|
"unchanged": True}
|
|
855
896
|
if disabled and role == "superadmin":
|
|
856
|
-
cur
|
|
857
|
-
|
|
897
|
+
_lock_admin_rows(cur)
|
|
898
|
+
cur.execute(f"SELECT count(*) FROM app_user WHERE {_ACTIVE_SUPERADMIN}")
|
|
858
899
|
if int(cur.fetchone()[0]) <= 1:
|
|
859
900
|
raise AuthError("cannot disable the last active superadmin")
|
|
860
901
|
cur.execute("UPDATE app_user SET disabled_at = %s WHERE id=%s",
|
|
@@ -1139,8 +1180,28 @@ def transfer_namespace(conn, namespace_id: str, new_owner_user_id: str, *,
|
|
|
1139
1180
|
# The receiving account has its own names and aliases, and (owner, name)
|
|
1140
1181
|
# is unique: without this the transfer dies on a constraint violation
|
|
1141
1182
|
# rather than on an explanation.
|
|
1142
|
-
|
|
1143
|
-
|
|
1183
|
+
#
|
|
1184
|
+
# The message deliberately does NOT say which of theirs it collides
|
|
1185
|
+
# with. Quoting the target's inventory made this a free, repeatable
|
|
1186
|
+
# probe of another tenant's namespace names — the check runs before the
|
|
1187
|
+
# UPDATE, so a positive costs the prober nothing.
|
|
1188
|
+
try:
|
|
1189
|
+
_require_name_free(cur, str(new_owner_user_id), name,
|
|
1190
|
+
except_namespace_id=namespace_id)
|
|
1191
|
+
except SpaceAmbiguous:
|
|
1192
|
+
raise SpaceAmbiguous(
|
|
1193
|
+
"the receiving account cannot hold a namespace by this name — "
|
|
1194
|
+
"rename it first, then transfer") from None
|
|
1195
|
+
# The per-account cap is enforced where namespaces are CREATED; a
|
|
1196
|
+
# transfer is the other way one arrives. Without this an account could
|
|
1197
|
+
# mint its 50, push them onto someone else, and repeat — leaving a
|
|
1198
|
+
# victim owning namespaces they never asked for and cannot delete.
|
|
1199
|
+
cur.execute("SELECT count(*) FROM namespace WHERE owner_user_id=%s",
|
|
1200
|
+
(_as_uuid(new_owner_user_id),))
|
|
1201
|
+
if cur.fetchone()[0] >= MAX_NAMESPACES_PER_USER:
|
|
1202
|
+
raise SpaceAmbiguous(
|
|
1203
|
+
f"the receiving account already owns {MAX_NAMESPACES_PER_USER} "
|
|
1204
|
+
"namespaces, which is the cap")
|
|
1144
1205
|
cur.execute("DELETE FROM namespace_member WHERE namespace_id=%s AND "
|
|
1145
1206
|
"user_id=%s", (_as_uuid(namespace_id), _as_uuid(new_owner_user_id)))
|
|
1146
1207
|
cur.execute("UPDATE namespace SET owner_user_id=%s WHERE id=%s",
|
|
@@ -1258,8 +1319,9 @@ def issue_token(conn, user_id: str, *, namespace_id: Optional[str] = None,
|
|
|
1258
1319
|
return secret, str(cur.fetchone()[0])
|
|
1259
1320
|
|
|
1260
1321
|
|
|
1261
|
-
def stash_secret(sink_dir: str, token_id: str, secret: str
|
|
1262
|
-
|
|
1322
|
+
def stash_secret(sink_dir: str, token_id: str, secret: str,
|
|
1323
|
+
*, suffix: str = ".token") -> str:
|
|
1324
|
+
"""Write a freshly minted secret to ``<sink_dir>/<token_id><suffix>`` (0600)
|
|
1263
1325
|
and return the path.
|
|
1264
1326
|
|
|
1265
1327
|
The out-of-band delivery channel for a deployment whose callers are agents.
|
|
@@ -1271,15 +1333,57 @@ def stash_secret(sink_dir: str, token_id: str, secret: str) -> str:
|
|
|
1271
1333
|
The directory is created 0700 if missing, and the file is opened with
|
|
1272
1334
|
``O_CREAT`` at 0600 so the secret is never briefly world-readable.
|
|
1273
1335
|
"""
|
|
1274
|
-
|
|
1275
|
-
path = os.path.join(sink_dir, f"{token_id}
|
|
1276
|
-
|
|
1277
|
-
with os.fdopen(fd, "w") as fh:
|
|
1278
|
-
fh.write(secret + "\n")
|
|
1279
|
-
os.chmod(path, 0o600) # tighten even if the file pre-existed
|
|
1336
|
+
_make_private_dir(sink_dir)
|
|
1337
|
+
path = os.path.join(sink_dir, f"{token_id}{suffix}")
|
|
1338
|
+
write_private(path, secret + "\n")
|
|
1280
1339
|
return path
|
|
1281
1340
|
|
|
1282
1341
|
|
|
1342
|
+
def _make_private_dir(path: str) -> None:
|
|
1343
|
+
"""Create `path` (and its parents) 0700, and tighten it if it already exists.
|
|
1344
|
+
|
|
1345
|
+
`os.makedirs(mode=…)` applies the mode to the LEAF only, and not at all to a
|
|
1346
|
+
directory that is already there — so a sink an operator made by hand at 0755
|
|
1347
|
+
stayed 0755, and every intermediate directory was created 0775. The
|
|
1348
|
+
docstring above used to promise otherwise, which is the worse half: an
|
|
1349
|
+
operator who read it believed the secrets were private.
|
|
1350
|
+
"""
|
|
1351
|
+
parts = []
|
|
1352
|
+
head = os.path.abspath(path)
|
|
1353
|
+
while head and not os.path.isdir(head):
|
|
1354
|
+
head, tail = os.path.split(head)
|
|
1355
|
+
parts.append(os.path.join(head, tail))
|
|
1356
|
+
for d in reversed(parts):
|
|
1357
|
+
os.mkdir(d, 0o700)
|
|
1358
|
+
if os.path.isdir(path) and not os.path.islink(path):
|
|
1359
|
+
os.chmod(path, 0o700)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def write_private(path: str, text: str) -> None:
|
|
1363
|
+
"""Write `text` to `path` as a fresh 0600 file, refusing to follow a symlink.
|
|
1364
|
+
|
|
1365
|
+
Three defects this closes, all measured:
|
|
1366
|
+
|
|
1367
|
+
* without ``O_NOFOLLOW`` the write follows a symlink, so anyone who can
|
|
1368
|
+
plant one where a secret is about to land redirects it into a file they
|
|
1369
|
+
own — proven end to end with the provisioning CLI;
|
|
1370
|
+
* without ``O_EXCL`` (and the unlink before it), ``O_CREAT``'s mode is
|
|
1371
|
+
IGNORED for a file that already exists, so the secret sat in a 0644 file
|
|
1372
|
+
for the duration of the write and only became 0600 afterwards;
|
|
1373
|
+
* a partially written secret is never left behind, because the target is
|
|
1374
|
+
removed and recreated rather than truncated in place.
|
|
1375
|
+
"""
|
|
1376
|
+
try:
|
|
1377
|
+
os.unlink(path)
|
|
1378
|
+
except FileNotFoundError:
|
|
1379
|
+
pass
|
|
1380
|
+
except IsADirectoryError:
|
|
1381
|
+
raise ValueError(f"{path} is a directory") from None
|
|
1382
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
1383
|
+
with os.fdopen(fd, "w") as fh:
|
|
1384
|
+
fh.write(text)
|
|
1385
|
+
|
|
1386
|
+
|
|
1283
1387
|
# ─── enrollment: bind a token the client generated itself ────────────────────
|
|
1284
1388
|
def new_enrollment_key() -> str:
|
|
1285
1389
|
"""A fresh one-time key: ``mge_`` + 43 url-safe chars."""
|
|
@@ -1381,6 +1485,15 @@ def redeem_enrollment(conn, key: str, secret: str) -> dict:
|
|
|
1381
1485
|
if cur.fetchone() is not None:
|
|
1382
1486
|
raise AuthError("that credential is already known to this server — "
|
|
1383
1487
|
"generate a fresh one")
|
|
1488
|
+
# An offboarded account still had usable keys in flight: the binding
|
|
1489
|
+
# succeeded, the key was spent, the reply said "bound" — and the token
|
|
1490
|
+
# was refused at the first call. No access was gained, but the answer
|
|
1491
|
+
# was a lie and the key was gone. Refuse while it still costs nothing.
|
|
1492
|
+
cur.execute("SELECT 1 FROM app_user WHERE id=%s AND disabled_at IS NOT NULL",
|
|
1493
|
+
(uid,))
|
|
1494
|
+
if cur.fetchone() is not None:
|
|
1495
|
+
raise AuthError("that account is disabled — ask whoever issued this "
|
|
1496
|
+
"key to re-enable it first")
|
|
1384
1497
|
tid = register_token(conn, str(uid), secret, namespace_id=
|
|
1385
1498
|
str(nsid) if nsid else None,
|
|
1386
1499
|
permission=perm, label=label or "enrolled")
|
|
@@ -1391,19 +1504,31 @@ def redeem_enrollment(conn, key: str, secret: str) -> dict:
|
|
|
1391
1504
|
"enrollment_id": str(eid)}
|
|
1392
1505
|
|
|
1393
1506
|
|
|
1394
|
-
def list_enrollments(conn, *, user_id: Optional[str] = None
|
|
1395
|
-
|
|
1507
|
+
def list_enrollments(conn, *, user_id: Optional[str] = None,
|
|
1508
|
+
plain_users_only: bool = False) -> List[dict]:
|
|
1509
|
+
"""Pending and spent keys — metadata only, never the key itself.
|
|
1510
|
+
|
|
1511
|
+
`plain_users_only` hides keys belonging to admin-role accounts, which is
|
|
1512
|
+
what the provisioning tier is allowed to see (see `admin.list_enrollments`).
|
|
1513
|
+
"""
|
|
1396
1514
|
# `expired` is computed by the DATABASE: the key's clock and the reader's
|
|
1397
1515
|
# are not the same clock, and a listing that disagreed with what redeeming
|
|
1398
1516
|
# does would be worse than no listing.
|
|
1399
|
-
sql = ("SELECT id, user_id, namespace_id, permission, label,
|
|
1400
|
-
"created_at, expires_at, used_at,
|
|
1401
|
-
"expires_at <= now() AS expired
|
|
1517
|
+
sql = ("SELECT k.id, k.user_id, k.namespace_id, k.permission, k.label, "
|
|
1518
|
+
"k.created_by, k.created_at, k.expires_at, k.used_at, "
|
|
1519
|
+
"k.used_token_id, k.revoked_at, k.expires_at <= now() AS expired "
|
|
1520
|
+
"FROM enrollment_key k")
|
|
1402
1521
|
args: list = []
|
|
1522
|
+
where = []
|
|
1403
1523
|
if user_id is not None:
|
|
1404
|
-
|
|
1524
|
+
where.append("k.user_id=%s")
|
|
1405
1525
|
args.append(user_id)
|
|
1406
|
-
|
|
1526
|
+
if plain_users_only:
|
|
1527
|
+
sql += " JOIN app_user u ON u.id = k.user_id"
|
|
1528
|
+
where.append("u.role = 'user'")
|
|
1529
|
+
if where:
|
|
1530
|
+
sql += " WHERE " + " AND ".join(where)
|
|
1531
|
+
sql += " ORDER BY k.created_at DESC, k.id"
|
|
1407
1532
|
with conn.cursor() as cur:
|
|
1408
1533
|
cur.execute(sql, args)
|
|
1409
1534
|
cols = [c.name for c in cur.description]
|
|
@@ -1578,14 +1703,42 @@ def request_access(conn, requester_user_id: str, namespace_id: str,
|
|
|
1578
1703
|
raise ValueError(
|
|
1579
1704
|
f"you already have {MAX_PENDING_REQUESTS_PER_USER} requests "
|
|
1580
1705
|
"waiting to be decided, which is the cap")
|
|
1706
|
+
# 🔴 A PENDING request may be LOWERED but never RAISED. It used to be
|
|
1707
|
+
# freely rewritable, so a requester could file `read`, let the owner see
|
|
1708
|
+
# `read` in `list_requests`, and raise it to `admin` before — or
|
|
1709
|
+
# during — the approval: approval grants what the row says when it is
|
|
1710
|
+
# read, so the admin authorized one thing and granted another. Measured
|
|
1711
|
+
# working both across the natural gap and inside the approver's own
|
|
1712
|
+
# transaction, since READ COMMITTED gives each statement its own
|
|
1713
|
+
# snapshot.
|
|
1714
|
+
#
|
|
1715
|
+
# Lowering stays allowed because it cannot be an over-grant, and because
|
|
1716
|
+
# a caller at the cap has no other way to withdraw an `admin` ask (an
|
|
1717
|
+
# amendment adds no row, so the cap does not apply to it). A DECIDED
|
|
1718
|
+
# request may be re-opened at any level: that is a fresh decision the
|
|
1719
|
+
# owner makes with the value in front of them.
|
|
1720
|
+
rank = "array_position(ARRAY['read','write','admin'], %s)"
|
|
1581
1721
|
cur.execute(
|
|
1582
1722
|
"INSERT INTO access_request (requester_user_id, namespace_id, "
|
|
1583
1723
|
"requested_permission) VALUES (%s, %s, %s) "
|
|
1584
1724
|
"ON CONFLICT (requester_user_id, namespace_id) DO UPDATE "
|
|
1585
1725
|
"SET requested_permission=EXCLUDED.requested_permission, "
|
|
1586
|
-
" status='pending', decided_at=NULL
|
|
1726
|
+
" status='pending', decided_at=NULL "
|
|
1727
|
+
"WHERE access_request.status <> 'pending' "
|
|
1728
|
+
f" OR {rank % 'EXCLUDED.requested_permission'} < "
|
|
1729
|
+
f" {rank % 'access_request.requested_permission'} "
|
|
1730
|
+
"RETURNING id, requested_permission",
|
|
1587
1731
|
(requester_user_id, namespace_id, permission))
|
|
1588
|
-
|
|
1732
|
+
row = cur.fetchone()
|
|
1733
|
+
if row is not None:
|
|
1734
|
+
return str(row[0])
|
|
1735
|
+
# The conflicting row was pending and at least as strong: it stands, and
|
|
1736
|
+
# the caller is told which permission is actually on the table.
|
|
1737
|
+
cur.execute("SELECT id, requested_permission FROM access_request "
|
|
1738
|
+
"WHERE requester_user_id=%s AND namespace_id=%s",
|
|
1739
|
+
(requester_user_id, namespace_id))
|
|
1740
|
+
row = cur.fetchone()
|
|
1741
|
+
return str(row[0])
|
|
1589
1742
|
|
|
1590
1743
|
|
|
1591
1744
|
def list_requests(conn, namespace_id: str, *, pending_only: bool = True) -> List[dict]:
|
|
@@ -1607,16 +1760,30 @@ def list_requests(conn, namespace_id: str, *, pending_only: bool = True) -> List
|
|
|
1607
1760
|
return out
|
|
1608
1761
|
|
|
1609
1762
|
|
|
1610
|
-
def approve_request(conn, request_id: str
|
|
1611
|
-
|
|
1763
|
+
def approve_request(conn, request_id: str, *,
|
|
1764
|
+
expect_permission: Optional[str] = None) -> None:
|
|
1765
|
+
"""Grant the requested membership and close the request.
|
|
1766
|
+
|
|
1767
|
+
`expect_permission` is what the approver SAW when they decided. Supplying it
|
|
1768
|
+
turns "the request changed under me" from a silent over-grant into a refusal.
|
|
1769
|
+
"""
|
|
1612
1770
|
with conn.cursor() as cur:
|
|
1771
|
+
# FOR UPDATE so the row cannot change between the authorization above
|
|
1772
|
+
# and the grant below. Belt to the braces in `request_access`: that stops
|
|
1773
|
+
# a pending request being amended at all, this stops any future writer
|
|
1774
|
+
# from reintroducing the race.
|
|
1613
1775
|
cur.execute("SELECT requester_user_id, namespace_id, requested_permission "
|
|
1614
|
-
"FROM access_request WHERE id=%s AND status='pending'"
|
|
1776
|
+
"FROM access_request WHERE id=%s AND status='pending' "
|
|
1777
|
+
"FOR UPDATE",
|
|
1615
1778
|
(request_id,))
|
|
1616
1779
|
row = cur.fetchone()
|
|
1617
1780
|
if row is None:
|
|
1618
1781
|
raise SpaceNotFound(f"no pending request {request_id}")
|
|
1619
1782
|
requester, nsid, perm = row
|
|
1783
|
+
if expect_permission is not None and perm != expect_permission:
|
|
1784
|
+
raise AuthError(
|
|
1785
|
+
f"this request now asks for '{perm}', not '{expect_permission}' "
|
|
1786
|
+
"— look at it again before approving")
|
|
1620
1787
|
add_member(conn, str(nsid), str(requester), perm)
|
|
1621
1788
|
with conn.cursor() as cur:
|
|
1622
1789
|
cur.execute("UPDATE access_request SET status='approved', decided_at=now() "
|
|
@@ -65,6 +65,14 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
|
|
|
65
65
|
"renew_on_read": bool(days > 0 and cfg.renew_on_read),
|
|
66
66
|
"policy": _retention_policy(days, cfg.renew_on_read),
|
|
67
67
|
},
|
|
68
|
+
# What a write MUST carry here. Announced rather than discovered from a
|
|
69
|
+
# refusal: a client that learns the rule by being rejected has already
|
|
70
|
+
# composed the memory, and a rule nobody can see before writing is a rule
|
|
71
|
+
# that gets satisfied with junk on the second attempt.
|
|
72
|
+
"write_requirements": {
|
|
73
|
+
"title": cfg.require_title,
|
|
74
|
+
"fields": list(cfg.required_fields),
|
|
75
|
+
},
|
|
68
76
|
"recall_modes": ["lexical"] if lexical_only
|
|
69
77
|
else ["lexical", "semantic", "hybrid", "auto"],
|
|
70
78
|
"vector_backend": cfg.vector_backend,
|