feat(memory): admin Edit + MEMORY_DOMAIN RBAC + ai-section UI (#141)

Cuts release 0.23.0.

## Highlights
- Single-item Edit button on every memory item card (modal hits PATCH /api/memory/admin/{id}).
- MEMORY_DOMAIN RBAC resource type — admins grant user_groups access to specific domains via /admin/access. Composes with existing audience filter (OR semantics, no-op when no grants).
- ai: section editable in /admin/server-config — admins can set ANTHROPIC_API_KEY / model / provider / base_url for the corporate-memory extractor without editing instance.yaml directly. api_key auto-masked.

## Devin findings addressed
- Modal NULL→empty fix (audience visibility wouldn't break).
- Stats endpoint granted_domains parity with list endpoint.
- Documented intentional MEMORY_DOMAIN→audience bypass.
- Documented conscious ai.base_url SSRF exclusion (legit internal LiteLLM/vLLM proxies).

See CHANGELOG [0.23.0] for full notes.
This commit is contained in:
ZdenekSrotyr 2026-04-30 11:04:41 +02:00 committed by GitHub
parent 83adf01bde
commit 70672204fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 380 additions and 26 deletions

View file

@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
## [0.23.0] — 2026-04-30
### Added
- **Single-item Edit button on every memory item card** in `/corporate-memory/admin`. Surfaces the per-item `PATCH /api/memory/admin/{id}` endpoint added in #126 — until now it was only reachable via the CLI (`da admin memory edit <id>`) or by selecting one item in the bulk batch bar. The modal pre-fills from the item's current title / content / category / domain (dropdown matching `VALID_DOMAINS` + `(unset)`) / audience / tags (comma-separated). Authorisation: same `require_admin` gate as the rest of the memory admin surface.
- **`ai` section editable in `/admin/server-config`**. The `ai:` block in `instance.yaml` (provider / api_key / model / base_url / structured_output for the corporate-memory extractor) was missing from `_EDITABLE_SECTIONS` and `SECTION_META`, so admins had no UI path to view or set the LLM token without editing `instance.yaml` directly. `api_key` is auto-masked via the existing `_SECRET_KEY_PATTERNS` (substring matches "api_key"), so the input renders as a password field and audit-log diffs redact the value.
- **`MEMORY_DOMAIN` RBAC resource type** for corporate-memory items. Admins use `/admin/access` to grant `user_groups` access to specific domains (one of `finance` / `engineering` / `product` / `data` / `operations` / `infrastructure`). Members of granted groups see all `knowledge_items` in that domain regardless of the existing `audience` string filter. The two filters compose with OR semantics, so the existing `audience='group:X'` convention keeps working unchanged for ad-hoc per-item targeting; pre-grant deployments behave identically (when no MEMORY_DOMAIN grants exist, the OR clause collapses to a no-op). Wired in `KnowledgeRepository.list_items` / `search` / `count_items` / `count_by_tag` / `count_by_audience` and in the inline SQL of `GET /api/memory/stats` via a new `granted_domains` parameter resolved from `resource_grants` by `_caller_granted_memory_domains`. **Note**: a MEMORY_DOMAIN grant is a parallel visibility path that pierces the `audience` field — an item with `audience='group:admins-only'` and `domain='finance'` becomes visible to anyone with a `MEMORY_DOMAIN/finance` grant. Operators who relied on `audience` as a hard access boundary should be aware (Devin ANALYSIS_0003 on PR #141).
### Fixed
- **Edit modal NULL→empty-string preservation** in `/corporate-memory/admin`. `submitEditItem` was sending `audience=""` for items whose stored audience was NULL, which silently broke visibility (the audience filter checks `audience IS NULL OR audience = 'all'`, neither of which matches empty string). Now empty form values for `audience`/`category`/`domain`/`content` are sent as JSON `null` so the backend stores NULL. (Devin BUG_0001 on PR #141 5f649a4 review.)
## [0.22.0] — 2026-04-30 ## [0.22.0] — 2026-04-30
### Fixed ### Fixed

View file

@ -109,6 +109,14 @@ def _normalize_primary_key(v):
# tuple is `(section, *intermediate_keys, leaf_key)` — same SSRF gate the # tuple is `(section, *intermediate_keys, leaf_key)` — same SSRF gate the
# /configure wizard applies to keboola_url, so an admin can't sneak # /configure wizard applies to keboola_url, so an admin can't sneak
# http://169.254.169.254/ in via the server-config editor's data_source patch. # http://169.254.169.254/ in via the server-config editor's data_source patch.
#
# Intentionally NOT included: ``("ai", "base_url")``. The openai_compat
# provider legitimately points at internal services (LiteLLM proxy on a
# private network, on-cluster vLLM endpoint, etc.) — see
# config/instance.yaml.example "LiteLLM proxy" example. SSRF blocking
# would break those valid setups. Operators with stricter posture should
# enforce the constraint upstream (firewall / egress proxy allowlist).
# Devin ANALYSIS_0001 on PR #141 5f649a4 review.
_URL_BEARING_FIELDS: tuple[tuple[str, ...], ...] = ( _URL_BEARING_FIELDS: tuple[tuple[str, ...], ...] = (
("data_source", "keboola", "stack_url"), ("data_source", "keboola", "stack_url"),
) )
@ -163,6 +171,7 @@ _EDITABLE_SECTIONS: tuple[str, ...] = (
"theme", "theme",
"server", "server",
"auth", "auth",
"ai",
) )
# "Danger-zone" sections — flipping these can lock operators out (auth.*) or # "Danger-zone" sections — flipping these can lock operators out (auth.*) or

View file

@ -79,6 +79,38 @@ def _effective_groups(
return [f"group:{r[0]}" for r in rows] return [f"group:{r[0]}" for r in rows]
def _caller_granted_memory_domains(
user: dict,
conn: duckdb.DuckDBPyConnection,
) -> Optional[List[str]]:
"""Domains the caller has been granted access to via resource_grants.
The grant model is generic admins assign ``MEMORY_DOMAIN`` resources
(e.g. ``finance``) to ``user_groups`` rows via ``/admin/access``. This
helper resolves the caller's group memberships against
``resource_grants`` and returns the union of domain strings.
Returns ``None`` for privileged viewers (admins see everything regardless
of grants same convention as ``_effective_groups``). Returns an
empty list when the caller has no grants the SQL filter then treats
this as a no-op (the ``OR domain IN ()`` clause is skipped).
"""
if _is_privileged_viewer(user, conn):
return None
user_id = user.get("id")
if not user_id:
return []
rows = conn.execute(
"""SELECT DISTINCT rg.resource_id
FROM resource_grants rg
JOIN user_group_members m ON m.group_id = rg.group_id
WHERE m.user_id = ?
AND rg.resource_type = 'memory_domain'""",
[user_id],
).fetchall()
return [r[0] for r in rows]
def _can_view_item(user: dict, item: dict, is_priv: bool) -> bool: def _can_view_item(user: dict, item: dict, is_priv: bool) -> bool:
"""Personal items are visible only to the contributor and privileged """Personal items are visible only to the contributor and privileged
viewers. Non-personal items are visible to any authenticated user. viewers. Non-personal items are visible to any authenticated user.
@ -195,12 +227,14 @@ async def list_knowledge(
# Their own personal contributions are visible via /my-contributions, not here. # Their own personal contributions are visible via /my-contributions, not here.
effective_exclude_personal = True if not _is_privileged_viewer(user, conn) else exclude_personal effective_exclude_personal = True if not _is_privileged_viewer(user, conn) else exclude_personal
effective_groups = _effective_groups(user, conn) effective_groups = _effective_groups(user, conn)
granted_domains = _caller_granted_memory_domains(user, conn)
statuses = [status_filter] if status_filter else None statuses = [status_filter] if status_filter else None
if search: if search:
items = repo.search( items = repo.search(
search, search,
exclude_personal=effective_exclude_personal, exclude_personal=effective_exclude_personal,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
statuses=statuses, statuses=statuses,
category=category, category=category,
domain=domain, domain=domain,
@ -216,6 +250,7 @@ async def list_knowledge(
source_type=source_type, source_type=source_type,
exclude_personal=effective_exclude_personal, exclude_personal=effective_exclude_personal,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
limit=per_page, limit=per_page,
offset=offset, offset=offset,
) )
@ -236,6 +271,7 @@ async def list_knowledge(
source_type=source_type, source_type=source_type,
exclude_personal=effective_exclude_personal, exclude_personal=effective_exclude_personal,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
) )
total_pages = math.ceil(total_count / per_page) if per_page > 0 else 1 total_pages = math.ceil(total_count / per_page) if per_page > 0 else 1
@ -270,6 +306,7 @@ async def get_stats(
""" """
is_priv = _is_privileged_viewer(user, conn) is_priv = _is_privileged_viewer(user, conn)
groups = _effective_groups(user, conn) groups = _effective_groups(user, conn)
granted_domains = _caller_granted_memory_domains(user, conn)
where_clauses: List[str] = [] where_clauses: List[str] = []
params: list = [] params: list = []
@ -283,16 +320,20 @@ async def get_stats(
where_clauses.append("(is_personal IS NULL OR is_personal = FALSE)") where_clauses.append("(is_personal IS NULL OR is_personal = FALSE)")
if groups is not None: if groups is not None:
# groups is None for admins → no audience filter; otherwise restrict to # Mirror the visibility composition KnowledgeRepository.list_items
# null/'all' or one of the caller's group audiences. # uses: audience match OR MEMORY_DOMAIN grant. Without this the
# stats `total` diverges from the list endpoint's `total_count` for
# non-admin users with grants (Devin BUG_0001 on PR #141 5f649a4).
visibility = ["audience IS NULL", "audience = 'all'"]
if groups: if groups:
placeholders = ",".join(["?"] * len(groups)) placeholders = ",".join(["?"] * len(groups))
where_clauses.append( visibility.append(f"audience IN ({placeholders})")
f"(audience IS NULL OR audience = 'all' OR audience IN ({placeholders}))"
)
params.extend(groups) params.extend(groups)
else: if granted_domains:
where_clauses.append("(audience IS NULL OR audience = 'all')") domain_placeholders = ",".join(["?"] * len(granted_domains))
visibility.append(f"domain IN ({domain_placeholders})")
params.extend(granted_domains)
where_clauses.append("(" + " OR ".join(visibility) + ")")
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
@ -336,10 +377,12 @@ async def get_stats(
by_tag = repo.count_by_tag( by_tag = repo.count_by_tag(
exclude_personal=exclude_personal_for_caller, exclude_personal=exclude_personal_for_caller,
user_groups=groups, user_groups=groups,
granted_domains=granted_domains,
) )
by_audience = repo.count_by_audience( by_audience = repo.count_by_audience(
exclude_personal=exclude_personal_for_caller, exclude_personal=exclude_personal_for_caller,
user_groups=groups, user_groups=groups,
granted_domains=granted_domains,
) )
return { return {
@ -1129,12 +1172,14 @@ async def get_tree(
# Audience-axis privacy (decision 13): non-admins only see their own # Audience-axis privacy (decision 13): non-admins only see their own
# group buckets + null/all. Use the audience pre-filter on the SQL side # group buckets + null/all. Use the audience pre-filter on the SQL side
# so non-admins never accidentally see another group's bucket count. # so non-admins never accidentally see another group's bucket count.
granted_domains = _caller_granted_memory_domains(user, conn)
statuses = [status_filter] if status_filter else None statuses = [status_filter] if status_filter else None
items = repo.list_items( items = repo.list_items(
statuses=statuses, statuses=statuses,
source_type=source_type, source_type=source_type,
exclude_personal=effective_exclude_personal, exclude_personal=effective_exclude_personal,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
limit=10000, limit=10000,
offset=0, offset=0,
) )
@ -1228,11 +1273,13 @@ async def get_bundle(
repo = KnowledgeRepository(conn) repo = KnowledgeRepository(conn)
effective_groups = _effective_groups(user, conn) effective_groups = _effective_groups(user, conn)
granted_domains = _caller_granted_memory_domains(user, conn)
mandatory = repo.list_items( mandatory = repo.list_items(
statuses=["mandatory"], statuses=["mandatory"],
exclude_personal=True, exclude_personal=True,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
limit=1000, limit=1000,
offset=0, offset=0,
) )
@ -1241,6 +1288,7 @@ async def get_bundle(
statuses=["approved"], statuses=["approved"],
exclude_personal=True, exclude_personal=True,
user_groups=effective_groups, user_groups=effective_groups,
granted_domains=granted_domains,
limit=1000, limit=1000,
offset=0, offset=0,
) )

View file

@ -44,6 +44,7 @@ class ResourceType(StrEnum):
MARKETPLACE_PLUGIN = "marketplace_plugin" MARKETPLACE_PLUGIN = "marketplace_plugin"
TABLE = "table" TABLE = "table"
MEMORY_DOMAIN = "memory_domain"
# Shape returned by ``list_blocks`` delegates. Kept as plain ``dict`` to keep # Shape returned by ``list_blocks`` delegates. Kept as plain ``dict`` to keep
@ -165,6 +166,53 @@ def _table_blocks(conn: "duckdb.DuckDBPyConnection") -> List[Block]:
return list(blocks.values()) return list(blocks.values())
# ---------------------------------------------------------------------------
# Memory domain projection
# ---------------------------------------------------------------------------
# Mirrors VALID_DOMAINS in app/api/memory.py. Kept inline here to avoid
# importing the FastAPI module at registry-load time (circular import risk).
# If this list drifts from VALID_DOMAINS, add a runtime cross-check or merge
# the two sources — for now they're tiny and reviewed together.
_MEMORY_DOMAINS = (
"finance",
"engineering",
"product",
"data",
"operations",
"infrastructure",
)
def _memory_domain_blocks(conn: "duckdb.DuckDBPyConnection") -> List[Block]:
"""Project the (fixed) set of corporate-memory domains into the
(block items) shape the admin UI renders.
Unlike marketplace plugins / tables, the grantable items are a fixed
enum, not a DB lookup every deployment has the same 6 domains. One
synthetic block ``"Memory domains"`` holds them; ``resource_id`` is
the domain string (matches ``knowledge_items.domain``).
"""
return [{
"id": "memory_domains",
"name": "Memory domains",
"items": [
{
"resource_id": domain,
"name": domain,
"category": "domain",
"description": (
f"Members of granted groups see all knowledge_items "
f"with domain={domain!r}, in addition to the existing "
f"audience filter."
),
}
for domain in _MEMORY_DOMAINS
],
}]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Registry — the one place that gets edited when adding a new resource type # Registry — the one place that gets edited when adding a new resource type
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -185,6 +233,13 @@ RESOURCE_TYPES: dict[ResourceType, ResourceTypeSpec] = {
id_format="<table_id>", id_format="<table_id>",
list_blocks=_table_blocks, list_blocks=_table_blocks,
), ),
ResourceType.MEMORY_DOMAIN: ResourceTypeSpec(
key=ResourceType.MEMORY_DOMAIN,
display_name="Memory domains",
description="A corporate-memory domain (knowledge_items.domain).",
id_format="<domain>",
list_blocks=_memory_domain_blocks,
),
} }

View file

@ -184,6 +184,7 @@ const SECTION_META = {
theme: { title: "Theme", help: "Brand colors and typography." }, theme: { title: "Theme", help: "Brand colors and typography." },
server: { title: "Server", help: "Hostname and host. Changing these can break OAuth callbacks." }, server: { title: "Server", help: "Hostname and host. Changing these can break OAuth callbacks." },
auth: { title: "Authentication", help: "Allowed sign-in domain and Google OAuth keys. Misconfiguration can lock everyone out." }, auth: { title: "Authentication", help: "Allowed sign-in domain and Google OAuth keys. Misconfiguration can lock everyone out." },
ai: { title: "AI / LLM", help: "Provider + API key for the corporate-memory extractor. provider=anthropic|openai_compat; api_key uses ${ENV_VAR} so the secret stays in .env." },
}; };
const DANGER_SECTIONS = new Set(["auth", "server"]); const DANGER_SECTIONS = new Set(["auth", "server"]);

View file

@ -1069,6 +1069,57 @@
</div> </div>
</div> </div>
<!-- Single-item edit modal — surfaces PATCH /api/memory/admin/{id}.
#126 added the endpoint + bulk batch bar but no per-item Edit
affordance, so admins had no UI path for "fix one item's
category / domain / audience / tags / title / content" without
dropping to the CLI. -->
<div id="editItemModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:2000;">
<div style="background: var(--surface); max-width: 640px; margin: 60px auto; padding: var(--space-5); border-radius: var(--radius-lg); border: 1px solid var(--border); max-height: 80vh; overflow-y: auto;">
<h3 style="margin-bottom: var(--space-3);">Edit item</h3>
<div id="editItemBody" style="display: flex; flex-direction: column; gap: var(--space-3);">
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Title</span>
<input id="editItemTitle" type="text" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text);">
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Content</span>
<textarea id="editItemContent" rows="6" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-family: inherit; resize: vertical;"></textarea>
</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3);">
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Category</span>
<input id="editItemCategory" type="text" placeholder="e.g. metric, dataset" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text);">
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Domain</span>
<select id="editItemDomain" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text);">
<option value="">(unset)</option>
<option value="finance">finance</option>
<option value="engineering">engineering</option>
<option value="product">product</option>
<option value="data">data</option>
<option value="operations">operations</option>
<option value="infrastructure">infrastructure</option>
</select>
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Audience</span>
<input id="editItemAudience" type="text" placeholder="e.g. all or group:finance" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text);">
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 12px; color: var(--text-muted);">Tags (comma-separated)</span>
<input id="editItemTags" type="text" placeholder="tag1, tag2" style="padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text);">
</label>
</div>
</div>
<div style="display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-4);">
<button class="btn" onclick="closeEditItemModal()">Cancel</button>
<button class="btn btn-mandate" onclick="submitEditItem()">Save</button>
</div>
</div>
</div>
<!-- Audit Log Tab --> <!-- Audit Log Tab -->
<div id="tab-audit" class="tab-content"> <div id="tab-audit" class="tab-content">
<div class="filter-bar"> <div class="filter-bar">
@ -1392,6 +1443,87 @@
} }
} }
// ─────────────────────────────────────────────────────────────
// Single-item edit (PATCH /api/memory/admin/{id})
// Surfaces the per-item edit endpoint added in #126; previously the
// only UI affordance was the bulk batch bar, which made "fix one
// item" awkward. Pre-fills from `_itemsById` (populated during card
// render) — no extra GET round-trip.
// ─────────────────────────────────────────────────────────────
let _editingItemId = null;
function openEditItemModal(itemId) {
const item = _itemsById.get(itemId);
if (!item) {
showToast('Item data unavailable — refresh and try again', 'error');
return;
}
_editingItemId = itemId;
document.getElementById('editItemTitle').value = item.title || '';
document.getElementById('editItemContent').value = item.content || '';
document.getElementById('editItemCategory').value = item.category || '';
document.getElementById('editItemDomain').value = item.domain || '';
document.getElementById('editItemAudience').value = item.audience || '';
const tagsArr = parseJsonField(item.tags);
document.getElementById('editItemTags').value = tagsArr.join(', ');
document.getElementById('editItemModal').style.display = 'block';
}
function closeEditItemModal() {
document.getElementById('editItemModal').style.display = 'none';
_editingItemId = null;
}
async function submitEditItem() {
if (!_editingItemId) return closeEditItemModal();
const title = document.getElementById('editItemTitle').value.trim();
if (!title) {
showToast('Title is required', 'error');
return;
}
// Convert empty strings to JSON null so the backend writes NULL,
// not an empty string. The audience filter in
// src/repositories/knowledge.py uses `audience IS NULL OR audience
// = 'all' OR audience IN (...)`, so an empty-string audience would
// silently make the item invisible to non-admin viewers (Devin
// BUG_0001 on PR #141 5f649a4 review). Same NULL-preservation
// applies to content / category / domain so unmodified fields
// round-trip correctly. Title is required so we never send null.
// Domain dropdown's "(unset)" option also funnels through this
// empty-string → null path.
const orNull = (s) => (s && s.trim() ? s.trim() : null);
const content = document.getElementById('editItemContent').value || null;
const category = orNull(document.getElementById('editItemCategory').value);
const domain = orNull(document.getElementById('editItemDomain').value);
const audience = orNull(document.getElementById('editItemAudience').value);
const tagsStr = document.getElementById('editItemTags').value;
const tags = tagsStr
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
const body = { title, content, category, domain, audience, tags };
try {
const resp = await fetch(`/api/memory/admin/${encodeURIComponent(_editingItemId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
showToast('Item updated', 'success');
closeEditItemModal();
refreshCurrentTab();
} else {
const data = await resp.json().catch(() => ({}));
showToast(data.detail || `Update failed (HTTP ${resp.status})`, 'error');
}
} catch (e) {
console.error('Edit failed:', e);
showToast('Network error: edit failed', 'error');
}
}
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Data loading // Data loading
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@ -1555,7 +1687,13 @@
// Rendering: Shared Item Card // Rendering: Shared Item Card
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Lookup map populated as items render — backs `openEditItemModal(id)`
// so the modal can pre-fill from the same payload the card was built
// from, without a fresh GET round-trip.
const _itemsById = new Map();
function renderItemCard(item, idx, isReview) { function renderItemCard(item, idx, isReview) {
_itemsById.set(item.id, item);
const status = item.status || 'pending'; const status = item.status || 'pending';
const categoryDisplay = (item.category || 'general').replace(/_/g, ' '); const categoryDisplay = (item.category || 'general').replace(/_/g, ' ');
const dateStr = (item.created_at || item.extracted_at || '').substring(0, 10) || 'recently'; const dateStr = (item.created_at || item.extracted_at || '').substring(0, 10) || 'recently';
@ -1587,6 +1725,7 @@
</svg> </svg>
Reject <span class="shortcut-hint">R</span> Reject <span class="shortcut-hint">R</span>
</button> </button>
<button class="btn" onclick="openEditItemModal('${esc(item.id)}')">Edit</button>
</div>`; </div>`;
} else { } else {
actionsHtml = buildStatusActions(item); actionsHtml = buildStatusActions(item);
@ -1669,6 +1808,10 @@
buttons.push(`<button class="btn btn-mandate" onclick="showMandateForm('${esc(item.id)}')">Make Mandatory</button>`); buttons.push(`<button class="btn btn-mandate" onclick="showMandateForm('${esc(item.id)}')">Make Mandatory</button>`);
} }
// Edit is always available regardless of status — admins fix
// category/domain/tags/audience/title/content via PATCH /admin/{id}.
buttons.push(`<button class="btn" onclick="openEditItemModal('${esc(item.id)}')">Edit</button>`);
if (buttons.length === 0) return ''; if (buttons.length === 0) return '';
return `<div class="item-actions">${buttons.join('')}</div>`; return `<div class="item-actions">${buttons.join('')}</div>`;
} }

View file

@ -1,6 +1,6 @@
[project] [project]
name = "agnes-the-ai-analyst" name = "agnes-the-ai-analyst"
version = "0.22.0" version = "0.23.0"
description = "Agnes — AI Data Analyst platform for AI analytical systems" description = "Agnes — AI Data Analyst platform for AI analytical systems"
requires-python = ">=3.11,<3.14" requires-python = ">=3.11,<3.14"
license = "MIT" license = "MIT"

View file

@ -113,6 +113,7 @@ class KnowledgeRepository:
source_type: Optional[str] = None, source_type: Optional[str] = None,
exclude_personal: bool = False, exclude_personal: bool = False,
user_groups: Optional[List[str]] = None, user_groups: Optional[List[str]] = None,
granted_domains: Optional[List[str]] = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@ -134,13 +135,20 @@ class KnowledgeRepository:
if exclude_personal: if exclude_personal:
query += " AND (is_personal = FALSE OR is_personal IS NULL)" query += " AND (is_personal = FALSE OR is_personal IS NULL)"
if user_groups is not None: if user_groups is not None:
# Audience filter: null/all → visible to everyone; group:X → only that group. # Visibility: audience-string match (null/all/group:X) OR
# caller has been granted access to the item's domain via
# resource_grants (MEMORY_DOMAIN). When ``granted_domains`` is
# falsy the OR clause collapses, preserving pre-RBAC behaviour.
visibility_clauses = ["audience IS NULL", "audience = 'all'"]
if user_groups: if user_groups:
audience_placeholders = ", ".join("?" for _ in user_groups) audience_placeholders = ", ".join("?" for _ in user_groups)
query += f" AND (audience IS NULL OR audience = 'all' OR audience IN ({audience_placeholders}))" visibility_clauses.append(f"audience IN ({audience_placeholders})")
params.extend(user_groups) params.extend(user_groups)
else: if granted_domains:
query += " AND (audience IS NULL OR audience = 'all')" domain_placeholders = ", ".join("?" for _ in granted_domains)
visibility_clauses.append(f"domain IN ({domain_placeholders})")
params.extend(granted_domains)
query += " AND (" + " OR ".join(visibility_clauses) + ")"
query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset]) params.extend([limit, offset])
return self._rows_to_dicts(self.conn.execute(query, params).fetchall()) return self._rows_to_dicts(self.conn.execute(query, params).fetchall())
@ -150,6 +158,7 @@ class KnowledgeRepository:
query: str, query: str,
exclude_personal: bool = False, exclude_personal: bool = False,
user_groups: Optional[List[str]] = None, user_groups: Optional[List[str]] = None,
granted_domains: Optional[List[str]] = None,
statuses: Optional[List[str]] = None, statuses: Optional[List[str]] = None,
category: Optional[str] = None, category: Optional[str] = None,
domain: Optional[str] = None, domain: Optional[str] = None,
@ -177,12 +186,16 @@ class KnowledgeRepository:
if exclude_personal: if exclude_personal:
sql += " AND (is_personal = FALSE OR is_personal IS NULL)" sql += " AND (is_personal = FALSE OR is_personal IS NULL)"
if user_groups is not None: if user_groups is not None:
visibility_clauses = ["audience IS NULL", "audience = 'all'"]
if user_groups: if user_groups:
audience_placeholders = ", ".join("?" for _ in user_groups) audience_placeholders = ", ".join("?" for _ in user_groups)
sql += f" AND (audience IS NULL OR audience = 'all' OR audience IN ({audience_placeholders}))" visibility_clauses.append(f"audience IN ({audience_placeholders})")
params.extend(user_groups) params.extend(user_groups)
else: if granted_domains:
sql += " AND (audience IS NULL OR audience = 'all')" domain_placeholders = ", ".join("?" for _ in granted_domains)
visibility_clauses.append(f"domain IN ({domain_placeholders})")
params.extend(granted_domains)
sql += " AND (" + " OR ".join(visibility_clauses) + ")"
sql += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" sql += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset]) params.extend([limit, offset])
results = self.conn.execute(sql, params).fetchall() results = self.conn.execute(sql, params).fetchall()
@ -197,6 +210,7 @@ class KnowledgeRepository:
source_type: Optional[str] = None, source_type: Optional[str] = None,
exclude_personal: bool = False, exclude_personal: bool = False,
user_groups: Optional[List[str]] = None, user_groups: Optional[List[str]] = None,
granted_domains: Optional[List[str]] = None,
) -> int: ) -> int:
if search: if search:
pattern = f"%{search}%" pattern = f"%{search}%"
@ -221,12 +235,16 @@ class KnowledgeRepository:
if exclude_personal: if exclude_personal:
sql += " AND (is_personal = FALSE OR is_personal IS NULL)" sql += " AND (is_personal = FALSE OR is_personal IS NULL)"
if user_groups is not None: if user_groups is not None:
visibility_clauses = ["audience IS NULL", "audience = 'all'"]
if user_groups: if user_groups:
audience_placeholders = ", ".join("?" for _ in user_groups) audience_placeholders = ", ".join("?" for _ in user_groups)
sql += f" AND (audience IS NULL OR audience = 'all' OR audience IN ({audience_placeholders}))" visibility_clauses.append(f"audience IN ({audience_placeholders})")
params.extend(user_groups) params.extend(user_groups)
else: if granted_domains:
sql += " AND (audience IS NULL OR audience = 'all')" domain_placeholders = ", ".join("?" for _ in granted_domains)
visibility_clauses.append(f"domain IN ({domain_placeholders})")
params.extend(granted_domains)
sql += " AND (" + " OR ".join(visibility_clauses) + ")"
return self.conn.execute(sql, params).fetchone()[0] return self.conn.execute(sql, params).fetchone()[0]
def list_by_domain( def list_by_domain(
@ -658,23 +676,29 @@ class KnowledgeRepository:
self, self,
exclude_personal: bool = False, exclude_personal: bool = False,
user_groups: Optional[List[str]] = None, user_groups: Optional[List[str]] = None,
granted_domains: Optional[List[str]] = None,
) -> Dict[str, int]: ) -> Dict[str, int]:
"""Aggregate item counts per tag (one tag may belong to many items). """Aggregate item counts per tag (one tag may belong to many items).
Uses DuckDB ``json_each`` to unnest the JSON tag list. Items with no Uses DuckDB ``json_each`` to unnest the JSON tag list. Items with no
tags don't contribute. Audience filter mirrors ``count_items``. tags don't contribute. Visibility filter mirrors ``count_items``
(audience OR MEMORY_DOMAIN grant).
""" """
where = ["tags IS NOT NULL"] where = ["tags IS NOT NULL"]
params: List[Any] = [] params: List[Any] = []
if exclude_personal: if exclude_personal:
where.append("(is_personal = FALSE OR is_personal IS NULL)") where.append("(is_personal = FALSE OR is_personal IS NULL)")
if user_groups is not None: if user_groups is not None:
visibility = ["audience IS NULL", "audience = 'all'"]
if user_groups: if user_groups:
ph = ",".join(["?"] * len(user_groups)) ph = ",".join(["?"] * len(user_groups))
where.append(f"(audience IS NULL OR audience = 'all' OR audience IN ({ph}))") visibility.append(f"audience IN ({ph})")
params.extend(user_groups) params.extend(user_groups)
else: if granted_domains:
where.append("(audience IS NULL OR audience = 'all')") dph = ",".join(["?"] * len(granted_domains))
visibility.append(f"domain IN ({dph})")
params.extend(granted_domains)
where.append("(" + " OR ".join(visibility) + ")")
where_sql = " WHERE " + " AND ".join(where) where_sql = " WHERE " + " AND ".join(where)
sql = ( sql = (
"SELECT t.value AS tag, COUNT(*) AS cnt " "SELECT t.value AS tag, COUNT(*) AS cnt "
@ -696,25 +720,31 @@ class KnowledgeRepository:
self, self,
exclude_personal: bool = False, exclude_personal: bool = False,
user_groups: Optional[List[str]] = None, user_groups: Optional[List[str]] = None,
granted_domains: Optional[List[str]] = None,
) -> Dict[str, int]: ) -> Dict[str, int]:
"""Aggregate item counts per audience bucket. """Aggregate item counts per audience bucket.
``audience`` is a free-form column whose canonical values are ``audience`` is a free-form column whose canonical values are
``NULL`` / ``'all'`` / ``'group:<name>'``. NULL is bucketed as ``NULL`` / ``'all'`` / ``'group:<name>'``. NULL is bucketed as
``'all'`` so the chip-filter UI doesn't need a separate "no audience" ``'all'`` so the chip-filter UI doesn't need a separate "no audience"
affordance. Audience filter mirrors ``count_items``. affordance. Visibility filter mirrors ``count_items`` (audience OR
MEMORY_DOMAIN grant).
""" """
where: List[str] = [] where: List[str] = []
params: List[Any] = [] params: List[Any] = []
if exclude_personal: if exclude_personal:
where.append("(is_personal = FALSE OR is_personal IS NULL)") where.append("(is_personal = FALSE OR is_personal IS NULL)")
if user_groups is not None: if user_groups is not None:
visibility = ["audience IS NULL", "audience = 'all'"]
if user_groups: if user_groups:
ph = ",".join(["?"] * len(user_groups)) ph = ",".join(["?"] * len(user_groups))
where.append(f"(audience IS NULL OR audience = 'all' OR audience IN ({ph}))") visibility.append(f"audience IN ({ph})")
params.extend(user_groups) params.extend(user_groups)
else: if granted_domains:
where.append("(audience IS NULL OR audience = 'all')") dph = ",".join(["?"] * len(granted_domains))
visibility.append(f"domain IN ({dph})")
params.extend(granted_domains)
where.append("(" + " OR ".join(visibility) + ")")
where_sql = (" WHERE " + " AND ".join(where)) if where else "" where_sql = (" WHERE " + " AND ".join(where)) if where else ""
sql = ( sql = (
"SELECT COALESCE(audience, 'all') AS aud, COUNT(*) AS cnt " "SELECT COALESCE(audience, 'all') AS aud, COUNT(*) AS cnt "

View file

@ -844,6 +844,64 @@ class TestAudienceDistribution:
ids = {i["id"] for i in r.json()["items"]} ids = {i["id"] for i in r.json()["items"]}
assert "aud_null2" in ids assert "aud_null2" in ids
def test_memory_domain_grant_extends_visibility(self, seeded_app):
"""A non-admin user whose group is granted MEMORY_DOMAIN/<domain>
sees items in that domain even when audience is restrictive
(audience='group:admins-only' that they're not in).
Wires together: resource_grants row + _caller_granted_memory_domains
helper + the OR domain IN (...) clause in list_items SQL."""
import uuid
from datetime import datetime, timezone
from src.db import get_system_db
conn = get_system_db()
# Item is restricted by audience to a group the analyst is NOT in,
# but tagged with domain=engineering.
self._seed_item(
conn, "dom_eng1", "Eng-only via domain grant",
audience="group:admins-only",
)
conn.execute(
"UPDATE knowledge_items SET domain = ? WHERE id = ?",
["engineering", "dom_eng1"],
)
# The seeded analyst (id="analyst1") has no implicit Everyone
# membership since 0.18.0 BREAKING change. Create a dedicated
# group, add the analyst, then grant MEMORY_DOMAIN/engineering.
gid = str(uuid.uuid4())
conn.execute(
"""INSERT INTO user_groups (id, name, description, created_by, created_at)
VALUES (?, 'eng-team', 'test', 'test', ?)""",
[gid, datetime.now(timezone.utc)],
)
conn.execute(
"""INSERT INTO user_group_members (user_id, group_id, source)
VALUES ('analyst1', ?, 'admin')""",
[gid],
)
conn.execute(
"""INSERT INTO resource_grants (id, group_id, resource_type, resource_id, assigned_at, assigned_by)
VALUES (?, ?, 'memory_domain', 'engineering', ?, 'test')""",
[str(uuid.uuid4()), gid, datetime.now(timezone.utc)],
)
conn.close()
# Without the grant the analyst would not see this item (audience
# gate excludes group:admins-only). With the grant on engineering
# domain, the OR clause restores visibility.
r = seeded_app["client"].get(
"/api/memory?per_page=500",
headers=_auth(seeded_app["analyst_token"]),
)
assert r.status_code == 200, r.text
ids = {i["id"] for i in r.json()["items"]}
assert "dom_eng1" in ids, (
"MEMORY_DOMAIN/engineering grant must make engineering items "
"visible regardless of audience filter"
)
class TestVoteRetract: class TestVoteRetract:
def _create_item(self, c, token): def _create_item(self, c, token):