Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1fbbadf
fix(permission): carry grant assignee ids as strings so revoke can fi…
Aug 10, 2026
8860ced
fix(permission): scope Catalog publish reads and label the state a fa…
Aug 11, 2026
c0a6b7e
style(permission): render the publish impact expiry as local time
Aug 11, 2026
be1e914
fix(permission): let one Catalog draft carry the whole edit batch
Aug 11, 2026
9aec574
fix(i18n): give the F048 permission error codes real copy
Aug 11, 2026
70e0145
fix(dashboard): stop reading visibility off an action list that never…
Aug 11, 2026
8f3be5c
feat(linsight): 新增 BiSheng 适配版 PPT 技能包
jieyuhuayang Aug 10, 2026
56b2497
fix(linsight): 代码执行器产物写穿工作区,跨轮追问不再丢交付物
jieyuhuayang Aug 10, 2026
294a0f3
feat(linsight): 内置技能随部署自带,启动时按内容幂等 seed
jieyuhuayang Aug 11, 2026
65aefa4
fix(linsight): 同步「执行器产物 ls 看不到」这条已过时的说明
jieyuhuayang Aug 11, 2026
7cca31e
perf(knowledge): stop paying for list actions the page never uses
Aug 11, 2026
2920a31
docs(alembic): inherit database charset defaults
zgqgit Aug 11, 2026
5e0dd15
Merge branch 'feat/3.0.0-beta1' of github.com:dataelement/bisheng int…
zgqgit Aug 11, 2026
ea2904e
fix(knowledge): a failed file must not take its knowledge container down
Aug 11, 2026
7281aa8
fix(permission): name why a knowledge target was rejected, in the log
Aug 11, 2026
be78708
fix(user): an empty page, not a 500, when the caller manages nobody
Aug 11, 2026
aaf1ed2
fix(permission): localize the model panel and offer a blank preset
Aug 11, 2026
b999e19
feat(linsight): 新增 bisheng-xlsx / bisheng-docx 内置技能,并修齐三个包的一致性
jieyuhuayang Aug 11, 2026
3f6bd0d
refactor(linsight): 删除从未被调用的 release_task_ownership
jieyuhuayang Aug 11, 2026
630b1cf
style(permission): don't park focus on the close button, don't clip t…
Aug 11, 2026
31bc7f6
style(permission): keep the uniform-grant select clear of its clippin…
Aug 12, 2026
9132323
style(permission): keep top-tier grants out of an ordinary owner's ha…
Aug 12, 2026
ca7e9aa
fix(assistant): name the knowledge space in the debug run log
Aug 12, 2026
e509879
fix(chat): keep the input usable after a retryable runtime error
Aug 12, 2026
25b6af4
fix(permission): stop the native select arrow overlapping the role text
Aug 12, 2026
a49cb93
refactor(permission): use the bs-ui Select in the action and model ed…
Aug 12, 2026
081a8f3
fix(permission): name the inherited parent, and stop repeating its cr…
Aug 12, 2026
a4139b9
fix(layout): size the content column from the flex row, not from 100vw
Aug 12, 2026
e44a556
style(permission): give the action board and the model editor one scr…
Aug 12, 2026
6127693
fix(permission): hide permissions for built-in tools; stop drafts loo…
Aug 12, 2026
7a2e7ed
fix(permission): delete a custom model in one action
Aug 12, 2026
a98ad54
fix(permission): report a failed model deletion instead of failing si…
Aug 12, 2026
f70222e
fix(permission): count what a mode switch drops, and label the snapsh…
Aug 12, 2026
e7de810
fix(permission): show which model a subject already holds in the gran…
Aug 12, 2026
139dcf6
fix(test): type the confirm-dialog mock so the lint gate passes
Aug 12, 2026
03e313e
fix(file-viewers): 把 xlsx 图片绑定到它真正所属的 sheet
jieyuhuayang Aug 12, 2026
c836ca5
feat(knowledge): 把 xlsx 内嵌图片提取进 MinIO
jieyuhuayang Aug 12, 2026
2f0ff2e
feat(permission): restore the resource-scoped grant-subject pickers
Aug 12, 2026
e586cbc
fix(permission): 授权对象选择器改问资源,不再问组织架构
Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,6 @@ docs-ui-refactor/
/.ds-sync/
/.design-sync/
/src/frontend/client/.ds-sync-gen/

# Packaged Linsight skill bundles (scripts/pack_linsight_skill.sh output)
/dist/

Large diffs are not rendered by default.

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions docs/linsight-skill-authoring.md

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions scripts/pack_linsight_skill.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Package a Linsight skill bundle into an importable .zip.
#
# bash scripts/pack_linsight_skill.sh src/backend/bisheng/linsight/builtin_skills/bisheng-pptx [outdir]
#
# Validates the constraints the backend enforces on import (SKILL.md at the
# archive root, kebab-case name matching the directory, size caps) so failures
# surface here rather than as an 11051/11052/11059 error in the admin UI.

set -euo pipefail

SRC="${1:-}"
OUT_DIR="${2:-dist}"

if [ -z "${SRC}" ]; then
echo "usage: bash scripts/pack_linsight_skill.sh <skill-dir> [outdir]" >&2
exit 2
fi

SRC="${SRC%/}"
NAME="$(basename "${SRC}")"

if [ ! -f "${SRC}/SKILL.md" ]; then
echo "[FAIL] ${SRC}/SKILL.md not found — SKILL.md must sit at the bundle root" >&2
exit 1
fi

# frontmatter name must equal the directory name (deepagents resolves skills by path)
FM_NAME="$(awk '/^---[[:space:]]*$/{n++; next} n==1 && /^name:/{sub(/^name:[[:space:]]*/, ""); gsub(/["\r]/, ""); print; exit}' "${SRC}/SKILL.md")"
if [ -z "${FM_NAME}" ]; then
echo "[FAIL] SKILL.md frontmatter has no 'name'" >&2
exit 1
fi
if [ "${FM_NAME}" != "${NAME}" ]; then
echo "[FAIL] frontmatter name '${FM_NAME}' != directory name '${NAME}'" >&2
exit 1
fi
if ! printf '%s' "${NAME}" | grep -Eq '^[a-z0-9]+(-[a-z0-9]+)*$'; then
echo "[FAIL] '${NAME}' is not kebab-case; import would silently rewrite it" >&2
exit 1
fi

mkdir -p "${OUT_DIR}"
ZIP_PATH="${OUT_DIR}/${NAME}.zip"
rm -f "${ZIP_PATH}"

ABS_ZIP="$(cd "${OUT_DIR}" && pwd)/${NAME}.zip"
(
cd "${SRC}"
zip -Xrq "${ABS_ZIP}" . \
-x '*/__pycache__/*' '__pycache__/*' '*.pyc' '.DS_Store' '*/.DS_Store'
)

ZIP_BYTES=$(wc -c < "${ZIP_PATH}" | tr -d ' ')
RAW_BYTES=$(find "${SRC}" -type f -not -path '*/__pycache__/*' -not -name '*.pyc' -not -name '.DS_Store' -exec wc -c {} + | tail -1 | awk '{print $1}')
FILE_COUNT=$(find "${SRC}" -type f -not -path '*/__pycache__/*' -not -name '*.pyc' -not -name '.DS_Store' | wc -l | tr -d ' ')

MAX_ZIP=$((10 * 1024 * 1024))
MAX_RAW=$((100 * 1024 * 1024))

printf '%s\n' "[OK] ${ZIP_PATH}"
printf ' name : %s\n' "${NAME}"
printf ' files : %s\n' "${FILE_COUNT}"
printf ' zip size : %s bytes (limit %s)\n' "${ZIP_BYTES}" "${MAX_ZIP}"
printf ' unpacked : %s bytes (limit %s)\n' "${RAW_BYTES}" "${MAX_RAW}"

STATUS=0
if [ "${ZIP_BYTES}" -gt "${MAX_ZIP}" ]; then
echo "[FAIL] zip exceeds the 10MB upload cap (error 11052)" >&2
STATUS=1
fi
if [ "${RAW_BYTES}" -gt "${MAX_RAW}" ]; then
echo "[FAIL] unpacked bundle exceeds the 100MB cap (error 11059)" >&2
STATUS=1
fi

if [ "${STATUS}" -eq 0 ]; then
echo " 导入方式: 管理端 → 灵思 → 技能 → 上传,选择上面的 zip(需要租户管理员权限)"
fi
exit "${STATUS}"
13 changes: 13 additions & 0 deletions src/backend/bisheng/core/database/alembic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ grandfathered: do **not** edit released migrations, but never add new ones like
constraints. Keep names stable so schema inspection remains understandable.
- Identifiers come back **uppercase** from DM8 reflection — compare
case-insensitively (the `*_exists` helpers already do).
- **Charset and collation follow the database defaults.** New migrations must not
normally pass MySQL-specific table options such as `mysql_charset` or
`mysql_collate` to `op.create_table()`. Charset and collation are deployment-level
database policy; hard-coding them in one migration makes schemas differ by creation
path, embeds MySQL-only semantics in a MySQL/DM8 codebase, and may change sorting or
uniqueness behaviour unexpectedly. Keep the corresponding SQLModel `__table_args__`
consistent with this rule so `create_all()` and Alembic do not create different table
definitions. An explicit table charset/collation is allowed only when it is a
documented data-contract requirement that cannot safely inherit the database default;
explain the exception in the migration and review its DM8 behaviour. A MySQL client
connection charset (for example `charset=utf8mb4`) controls transport encoding and
does not establish the database or table default. Do not edit an already-released
migration merely to remove legacy charset/collation options.
- **Reusable DDL guards** go in `alembic_helpers/online.py` (`table_exists`,
`column_exists`) so revisions stay thin. Do **not** treat `alembic_helpers/f011.py`
as a template — it holds read-then-write *data* logic from a pre-rule revision, which
Expand Down
12 changes: 12 additions & 0 deletions src/backend/bisheng/core/openfga/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ async def read_tuples(
) -> list[dict]:
"""Read tuples matching the given filter.

Pass no filter at all to walk the whole Store. Any other combination must
satisfy the Read API: the object type is mandatory, and the object id and
the user cannot both be empty. Callers used to learn this the hard way —
the server answers a filter it dislikes with a generic validation_error,
which surfaced as a 500 only after the surrounding work had already run.

Returns list of {"key": {"user": ..., "relation": ..., "object": ...}, "timestamp": ...}.
"""
tuple_key: dict[str, str] = {}
Expand All @@ -277,6 +283,12 @@ async def read_tuples(
tuple_key["relation"] = relation
if object:
tuple_key["object"] = object
if tuple_key:
object_type, separator, object_id = (object or "").partition(":")
if not object_type or not separator:
raise ValueError(f"OpenFGA read filter needs an object type, got object={object!r}")
if not object_id and not user:
raise ValueError("OpenFGA read filter needs an object id or a user, got neither")
tuples: list[dict] = []
continuation_token: str | None = None
while True:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,31 @@ async def aget_path_tree(cls, login_user, dept_id: int, include_archived: bool =
roots = await cls._abuild_pruned_forest([target], {target.id}, is_sys_admin, admin_paths, include_archived)
return {"roots": roots, "total_matches": 1, "truncated": False}

@classmethod
async def abuild_forest_within_subtree(
cls,
seeds,
matched_ids: set[int],
*,
confined_to_path: str | None = None,
include_archived: bool = False,
) -> list[dict]:
"""The same pruned-forest assembly, for a caller whose scope is a
department subtree rather than an administrator's scope.

The grant-subject pickers (F048) ask "who may be granted this resource",
so their visibility is the resource's own department binding — not who
the caller administers. ``confined_to_path=None`` means the whole tenant.
"""

return await cls._abuild_pruned_forest(
seeds,
matched_ids,
confined_to_path is None,
{confined_to_path} if confined_to_path else set(),
include_archived,
)

@classmethod
async def _abuild_pruned_forest(
cls, seeds, matched_ids: set[int], is_sys_admin: bool, admin_paths: set[str], include_archived: bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,58 @@ async def remove_ordinary_sources(self, **kwargs): ...
async def sync_public_reader(self, **kwargs): ...


_CONTAINER_KINDS = {
"knowledge_space": {"SPACE"},
"knowledge_library": {"NORMAL", "QA"},
}


def _container_rejection(
record: KnowledgeContainerPermissionRecord | None,
actor: PermissionActor,
resource_type: str,
resource_id: str,
*,
allowed_statuses: set[str],
) -> str | None:
"""Name why a container cannot serve as a permission target, or None.

The reason never reaches the caller: `permission_error_response` flattens
every rejection into the same 19003 body so that a missing resource and one
in another tenant stay indistinguishable. It goes to the log instead —
without it, "Invalid resource type or ID" covers seven different causes, and
a space stuck at status=FAILED reads exactly like a typo in the id.
"""

if record is None:
return "NOT_FOUND"
if resource_type not in _CONTAINER_KINDS:
return "UNSUPPORTED_RESOURCE_TYPE"
if record.resource_type != resource_type or record.resource_id != resource_id:
return "IDENTITY_MISMATCH"
if record.kind not in _CONTAINER_KINDS[resource_type]:
return f"KIND_MISMATCH:{record.kind}"
if record.status not in allowed_statuses:
return f"STATUS_NOT_USABLE:{record.status}"
if record.tenant_id != actor.current_tenant_id and not actor.super_admin:
return "TENANT_MISMATCH"
return None


def _reject_container(
reason: str,
resource_type: str,
resource_id: str,
) -> PermissionInvalidResourceError:
logger.warning(
"permission target rejected: reason=%s resource=%s:%s",
reason,
resource_type,
resource_id,
)
return PermissionInvalidResourceError()


class F048KnowledgeContainerPermissionAdapter:
"""Keep container business validation outside the permission domain."""

Expand Down Expand Up @@ -639,20 +691,15 @@ def _target(
resource_type: str,
resource_id: str,
) -> VerifiedPermissionTarget:
expected_kinds = {
"knowledge_space": {"SPACE"},
"knowledge_library": {"NORMAL", "QA"},
}
if (
record is None
or resource_type not in expected_kinds
or record.resource_type != resource_type
or record.resource_id != resource_id
or record.status != "PUBLISHED"
or record.kind not in expected_kinds[resource_type]
or (record.tenant_id != actor.current_tenant_id and not actor.super_admin)
):
raise PermissionInvalidResourceError()
reason = _container_rejection(
record,
actor,
resource_type,
resource_id,
allowed_statuses={"PUBLISHED"},
)
if reason is not None:
raise _reject_container(reason, resource_type, resource_id)
return VerifiedPermissionTarget.from_business_service(
tenant_id=record.tenant_id,
resource_type=record.resource_type,
Expand All @@ -668,20 +715,15 @@ def _lifecycle_target(
resource_type: str,
resource_id: str,
) -> VerifiedPermissionTarget:
expected_kinds = {
"knowledge_space": {"SPACE"},
"knowledge_library": {"NORMAL", "QA"},
}
if (
record is None
or resource_type not in expected_kinds
or record.resource_type != resource_type
or record.resource_id != resource_id
or record.status not in {state.name for state in KnowledgeState}
or record.kind not in expected_kinds[resource_type]
or (record.tenant_id != actor.current_tenant_id and not actor.super_admin)
):
raise PermissionInvalidResourceError()
reason = _container_rejection(
record,
actor,
resource_type,
resource_id,
allowed_statuses={state.name for state in KnowledgeState},
)
if reason is not None:
raise _reject_container(reason, resource_type, resource_id)
return VerifiedPermissionTarget.from_business_service(
tenant_id=record.tenant_id,
resource_type=record.resource_type,
Expand Down
17 changes: 13 additions & 4 deletions src/backend/bisheng/knowledge/domain/services/knowledge_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,8 @@ async def get_knowledge(
batch_action_map = await cls.permission_service.get_knowledge_action_map_async(
login_user,
[int(one.id) for one in batch],
_KNOWLEDGE_LIST_ACTIONS,
[action],
)
action_map.update(batch_action_map)
authorized.extend(one for one in batch if action in batch_action_map.get(int(one.id), set()))
if len(batch) < _KNOWLEDGE_PERMISSION_SCAN_BATCH_SIZE:
break
Expand All @@ -515,9 +514,8 @@ async def get_knowledge(
batch_action_map = await cls.permission_service.get_knowledge_action_map_async(
login_user,
[int(one.id) for one in batch],
_KNOWLEDGE_LIST_ACTIONS,
[action],
)
action_map.update(batch_action_map)
res.extend(one for one in batch if action in batch_action_map.get(int(one.id), set()))
if len(res) >= fetch_limit or len(batch) < _KNOWLEDGE_PERMISSION_SCAN_BATCH_SIZE:
break
Expand All @@ -542,6 +540,17 @@ async def get_knowledge(
if has_more:
res = res[:page_size]

# The other list actions only decorate the rows that survived, so they
# are resolved once the page is known. Asking for all of them per
# candidate multiplied the scan by the number of actions, and that cost
# grew with every extra scan round instead of with the page.
if res:
action_map = await cls.permission_service.get_knowledge_action_map_async(
login_user,
[int(one.id) for one in res],
_KNOWLEDGE_LIST_ACTIONS,
)

# ---- 4. Enrich + build response ----
enrich_start = perf_counter()
result_data = await cls.aconvert_knowledge_read(
Expand Down
Loading
Loading