Skip to content

Commit b472355

Browse files
committed
πŸ› fix: require USAGE/CREATE privileges on POSTGRES_SCHEMA
Schema-existence alone doesn't guarantee the app role can actually create or read from it β€” a role with USAGE but no CREATE on `myapp`, and CREATE on `public`, would still have pgvector tables silently land in `public` because `_build_search_path` appends `public` as a fallback for vector-type resolution. Check has_schema_privilege('USAGE') and has_schema_privilege('CREATE') for each requested schema via a single query, and raise a combined error listing exactly which schemas are missing vs. which lack which grant so operators know what to fix.
1 parent e7a1a48 commit b472355

1 file changed

Lines changed: 31 additions & 12 deletions

File tree

β€Žapp/services/vector_store/factory.pyβ€Ž

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ def _build_search_path(schemas: List[str]) -> str:
3434

3535

3636
def _verify_schemas_exist(connection_string: str, schemas: List[str]) -> None:
37-
"""Raise if any requested schema doesn't exist in the target database.
37+
"""Raise if any requested schema is missing or lacks USAGE/CREATE for the
38+
connecting role.
3839
3940
Silent fallback is worse than failing fast here: PostgreSQL resolves
4041
unqualified CREATE TABLE against the first schema in search_path where
41-
the role has CREATE privileges, so a typo in POSTGRES_SCHEMA would
42-
land the pgvector tables in `public` instead of the intended namespace,
43-
silently defeating the isolation this feature is meant to provide.
42+
the role has CREATE privileges. A typo or a privilege gap on the target
43+
schema would therefore land the pgvector tables in `public` (the next
44+
entry in the search_path appended by _build_search_path) instead of the
45+
intended namespace, silently defeating the isolation this feature is
46+
meant to provide.
4447
"""
4548
from sqlalchemy import create_engine, text
4649

@@ -49,19 +52,35 @@ def _verify_schemas_exist(connection_string: str, schemas: List[str]) -> None:
4952
with engine.connect() as conn:
5053
rows = conn.execute(
5154
text(
52-
"SELECT schema_name FROM information_schema.schemata "
53-
"WHERE schema_name = ANY(:names)"
55+
"SELECT s.schema_name, "
56+
" has_schema_privilege(s.schema_name, 'USAGE') AS has_usage, "
57+
" has_schema_privilege(s.schema_name, 'CREATE') AS has_create "
58+
"FROM information_schema.schemata s "
59+
"WHERE s.schema_name = ANY(:names)"
5460
),
5561
{"names": schemas},
5662
).fetchall()
57-
existing = {r[0] for r in rows}
58-
missing = [s for s in schemas if s not in existing]
63+
found = {row[0]: (row[1], row[2]) for row in rows}
64+
65+
missing = [s for s in schemas if s not in found]
66+
no_usage = [s for s in schemas if s in found and not found[s][0]]
67+
no_create = [s for s in schemas if s in found and not found[s][1]]
68+
69+
problems = []
5970
if missing:
71+
problems.append(f"does not exist: {missing!r}")
72+
if no_usage:
73+
problems.append(f"role lacks USAGE on: {no_usage!r}")
74+
if no_create:
75+
problems.append(f"role lacks CREATE on: {no_create!r}")
76+
77+
if problems:
78+
target = (missing or no_usage or no_create)[0]
6079
raise ValueError(
61-
f"POSTGRES_SCHEMA: schema(s) {missing!r} do not exist. "
62-
"Create them out-of-band first (e.g. "
63-
f"`CREATE SCHEMA IF NOT EXISTS {missing[0]}; "
64-
f"GRANT USAGE, CREATE ON SCHEMA {missing[0]} TO <app_user>;`)."
80+
"POSTGRES_SCHEMA: " + "; ".join(problems) + ". "
81+
"Create/grant out-of-band first (e.g. "
82+
f"`CREATE SCHEMA IF NOT EXISTS {target}; "
83+
f"GRANT USAGE, CREATE ON SCHEMA {target} TO <app_user>;`)."
6584
)
6685
finally:
6786
engine.dispose()

0 commit comments

Comments
Β (0)