feat: append public row
This commit is contained in:
@@ -11,10 +11,14 @@ from lnbits.helpers import sha256s
|
||||
|
||||
from ..client.extensions import send_extension_api_request
|
||||
from ..storage.crud import (
|
||||
OWNER_ID_FIELD,
|
||||
storage_append_public_row,
|
||||
storage_count_rows,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_get_row_owner_id,
|
||||
storage_set_row,
|
||||
)
|
||||
from .background_payments import (
|
||||
@@ -39,6 +43,8 @@ from .models import (
|
||||
PayLnurlRequest,
|
||||
RandomIdRequest,
|
||||
RandomIdResponse,
|
||||
StorageAppendPublicRequest,
|
||||
StorageAppendPublicResponse,
|
||||
StorageDeleteRequest,
|
||||
StorageDeleteResponse,
|
||||
StorageGetRequest,
|
||||
@@ -54,6 +60,7 @@ from .models import (
|
||||
from .registry import extension_api_method
|
||||
|
||||
logger = logging.getLogger("lnbits.extensions")
|
||||
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
||||
|
||||
|
||||
class ExtensionHostAPI:
|
||||
@@ -129,6 +136,45 @@ class ExtensionHostAPI:
|
||||
# todo: check public fields filtering
|
||||
return StorageGetResponse(data_json=json.dumps(public_row))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.append_public",
|
||||
namespace="storage",
|
||||
name="Append public storage row",
|
||||
host_name="storage_append_public",
|
||||
sdk_name="appendPublic",
|
||||
description="Append one public row to an extension storage table.",
|
||||
required_permission="ext.storage.append_public",
|
||||
require_auth=False,
|
||||
)
|
||||
async def storage_append_public(
|
||||
self, request: StorageAppendPublicRequest
|
||||
) -> StorageAppendPublicResponse:
|
||||
policy, owner_id = await self._public_storage_append_policy(
|
||||
request.table, request.source_id
|
||||
)
|
||||
data = dict(request.data)
|
||||
self._validate_public_append_data(policy, data)
|
||||
|
||||
source_id_field = policy["source_id_field"]
|
||||
current_rows = await storage_count_rows(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
{source_id_field: request.source_id},
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if current_rows >= policy["max_rows_per_source"]:
|
||||
raise PermissionError(
|
||||
f"Public storage append limit reached for '{request.table}'."
|
||||
)
|
||||
|
||||
row_id = await storage_append_public_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
{**data, source_id_field: request.source_id},
|
||||
owner_id,
|
||||
)
|
||||
return StorageAppendPublicResponse(id=row_id)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.set",
|
||||
namespace="storage",
|
||||
@@ -652,6 +698,98 @@ class ExtensionHostAPI:
|
||||
|
||||
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
||||
|
||||
async def _public_storage_append_policy(
|
||||
self, table: str, source_id: str
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
policies = self.permission_policies.get("ext.storage.append_public")
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError(
|
||||
"Public storage appends require policies for "
|
||||
"'ext.storage.append_public'."
|
||||
)
|
||||
|
||||
source_not_found = False
|
||||
for raw_policy in policies:
|
||||
policy = self._normalize_public_storage_append_policy(raw_policy)
|
||||
if policy["table"] != table:
|
||||
continue
|
||||
owner_id = await storage_get_row_owner_id(
|
||||
self.extension_id,
|
||||
policy["source_table"],
|
||||
source_id,
|
||||
)
|
||||
if not owner_id:
|
||||
source_not_found = True
|
||||
continue
|
||||
return policy, owner_id
|
||||
|
||||
if source_not_found:
|
||||
raise PermissionError("Public storage append source was not found.")
|
||||
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
|
||||
|
||||
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
|
||||
if not isinstance(policy, dict):
|
||||
raise PermissionError("Public storage append policies must be objects.")
|
||||
|
||||
table = policy.get("table")
|
||||
source_table = policy.get("source_table")
|
||||
source_id_field = policy.get("source_id_field")
|
||||
allowed_fields = policy.get("allowed_fields")
|
||||
max_rows_per_source = policy.get(
|
||||
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
|
||||
)
|
||||
|
||||
if not isinstance(table, str) or not table:
|
||||
raise PermissionError("Public storage append requires a table policy.")
|
||||
if not isinstance(source_table, str) or not source_table:
|
||||
raise PermissionError(
|
||||
"Public storage append requires a source table policy."
|
||||
)
|
||||
if not isinstance(source_id_field, str) or not source_id_field:
|
||||
raise PermissionError(
|
||||
"Public storage append requires a source ID field policy."
|
||||
)
|
||||
if source_id_field == "id":
|
||||
raise PermissionError("Public storage append source field cannot be 'id'.")
|
||||
if (
|
||||
not isinstance(allowed_fields, list)
|
||||
or not all(isinstance(field, str) and field for field in allowed_fields)
|
||||
or "id" in allowed_fields
|
||||
or OWNER_ID_FIELD in allowed_fields
|
||||
or source_id_field in allowed_fields
|
||||
):
|
||||
raise PermissionError(
|
||||
"Public storage append requires valid allowed fields."
|
||||
)
|
||||
if (
|
||||
isinstance(max_rows_per_source, bool)
|
||||
or not isinstance(max_rows_per_source, int)
|
||||
or max_rows_per_source <= 0
|
||||
):
|
||||
raise PermissionError(
|
||||
"Public storage append requires a positive row limit."
|
||||
)
|
||||
|
||||
return {
|
||||
"table": table,
|
||||
"source_table": source_table,
|
||||
"source_id_field": source_id_field,
|
||||
"allowed_fields": set(allowed_fields),
|
||||
"max_rows_per_source": max_rows_per_source,
|
||||
}
|
||||
|
||||
def _validate_public_append_data(
|
||||
self, policy: dict[str, Any], data: dict[str, Any]
|
||||
) -> None:
|
||||
if not isinstance(data, dict):
|
||||
raise PermissionError("Public storage append data must be an object.")
|
||||
unknown_fields = sorted(set(data) - policy["allowed_fields"])
|
||||
if unknown_fields:
|
||||
raise PermissionError(
|
||||
"Public storage append contains disallowed fields: "
|
||||
+ ", ".join(unknown_fields)
|
||||
)
|
||||
|
||||
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
||||
policies = self.permission_policies.get("wallet.create_invoice_public")
|
||||
if not isinstance(policies, list) or not policies:
|
||||
|
||||
@@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class StorageAppendPublicRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
source_id: str = Field(..., min_length=1, max_length=512)
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
|
||||
class StorageAppendPublicResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class StoragePaginatedRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
filters: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -10,11 +10,14 @@ from lnbits.core.wasm_ext.wasm.config import (
|
||||
)
|
||||
|
||||
_POLICY_AWARE_PERMISSION_IDS = {
|
||||
"ext.storage.append_public",
|
||||
"ext.storage.read_public",
|
||||
"extension.api.request",
|
||||
"http.request",
|
||||
"wallet.create_invoice_public",
|
||||
}
|
||||
_OWNER_ID_FIELD = "__lnbits_owner_id__"
|
||||
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
||||
|
||||
|
||||
def validate_extension_permissions(
|
||||
@@ -127,6 +130,10 @@ def _permission_grant_is_subset(
|
||||
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "extension.api.request":
|
||||
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "ext.storage.append_public":
|
||||
return _public_storage_append_grant_is_subset(
|
||||
requested.policies, granted.policies
|
||||
)
|
||||
if requested.id == "ext.storage.read_public":
|
||||
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "wallet.create_invoice_public":
|
||||
@@ -229,6 +236,64 @@ def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
|
||||
return tables
|
||||
|
||||
|
||||
def _public_storage_append_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
requested_targets = _public_storage_append_targets(requested_policies)
|
||||
granted_targets = _public_storage_append_targets(granted_policies)
|
||||
for target, granted_policy in granted_targets.items():
|
||||
requested_policy = requested_targets.get(target)
|
||||
if requested_policy is None:
|
||||
return False
|
||||
if not granted_policy["allowed_fields"].issubset(
|
||||
requested_policy["allowed_fields"]
|
||||
):
|
||||
return False
|
||||
if (
|
||||
granted_policy["max_rows_per_source"]
|
||||
> requested_policy["max_rows_per_source"]
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
|
||||
targets: dict[Any, dict] = {}
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table = policy.get("table")
|
||||
source_table = policy.get("source_table")
|
||||
source_id_field = policy.get("source_id_field")
|
||||
allowed_fields = policy.get("allowed_fields")
|
||||
max_rows_per_source = policy.get(
|
||||
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
|
||||
)
|
||||
if (
|
||||
not isinstance(table, str)
|
||||
or not table
|
||||
or not isinstance(source_table, str)
|
||||
or not source_table
|
||||
or not isinstance(source_id_field, str)
|
||||
or not source_id_field
|
||||
or source_id_field == "id"
|
||||
or not isinstance(allowed_fields, list)
|
||||
or isinstance(max_rows_per_source, bool)
|
||||
or not isinstance(max_rows_per_source, int)
|
||||
or max_rows_per_source <= 0
|
||||
):
|
||||
continue
|
||||
fields = {field for field in allowed_fields if isinstance(field, str) and field}
|
||||
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
|
||||
continue
|
||||
targets[(table, source_table, source_id_field)] = {
|
||||
"allowed_fields": fields,
|
||||
"max_rows_per_source": max_rows_per_source,
|
||||
}
|
||||
return targets
|
||||
|
||||
|
||||
def _public_invoice_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from .crud import (
|
||||
migrate_wasm_extension_database,
|
||||
storage_append_public_row,
|
||||
storage_count_rows,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
@@ -10,6 +12,8 @@ from .crud import (
|
||||
|
||||
__all__ = [
|
||||
"migrate_wasm_extension_database",
|
||||
"storage_append_public_row",
|
||||
"storage_count_rows",
|
||||
"storage_delete_row",
|
||||
"storage_get_paginated_rows",
|
||||
"storage_get_public_row",
|
||||
|
||||
@@ -5,6 +5,7 @@ import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -110,6 +111,39 @@ async def storage_set_row(
|
||||
await conn.execute(query, clean_data)
|
||||
|
||||
|
||||
async def storage_append_public_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
) -> str:
|
||||
row_id = uuid4().hex
|
||||
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
|
||||
return row_id
|
||||
|
||||
|
||||
async def storage_count_rows(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
filters: dict[str, Any],
|
||||
*,
|
||||
owner_id: str,
|
||||
) -> int:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
database = Database(f"ext_{ext_id}")
|
||||
where_sql, values = _where_sql(database, table_schema, filters, None, [])
|
||||
where_sql = _append_owner_where_sql(where_sql)
|
||||
values[OWNER_ID_FIELD] = owner_id
|
||||
|
||||
query = f"""
|
||||
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
|
||||
{where_sql}
|
||||
""" # noqa: S608
|
||||
async with database.connect() as conn:
|
||||
row = await conn.fetchone(query, values)
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
async def storage_get_paginated_rows(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
|
||||
@@ -544,6 +544,10 @@ window.localisation.en = {
|
||||
extension_permission_warning_extension_api_request_write:
|
||||
'Can write data or trigger actions in approved extensions.',
|
||||
extension_permission_ext_storage_read: 'Read extension storage',
|
||||
extension_permission_ext_storage_append_public:
|
||||
'Append public extension storage',
|
||||
extension_permission_ext_storage_append_public_sources:
|
||||
'Allowed append targets',
|
||||
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
||||
extension_permission_ext_storage_write: 'Write extension storage',
|
||||
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'wallet.create_invoice_public',
|
||||
'ext.storage.append_public',
|
||||
'ext.storage.read_public'
|
||||
].includes(permission.id)
|
||||
) {
|
||||
@@ -145,6 +146,7 @@
|
||||
'ext.storage.read',
|
||||
'ext.storage.write',
|
||||
'ext.storage.read_public',
|
||||
'ext.storage.append_public',
|
||||
'wallet.create_invoice_public',
|
||||
'wallet.create_invoice',
|
||||
'utils.basic'
|
||||
@@ -194,6 +196,37 @@
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function publicAppendPolicies(permission) {
|
||||
const policies = permission.policies
|
||||
if (!Array.isArray(policies)) return []
|
||||
return policies
|
||||
.map(policy => {
|
||||
if (!policy || typeof policy !== 'object') return null
|
||||
const table = policy.table
|
||||
const sourceTable = policy.source_table
|
||||
const sourceIdField = policy.source_id_field
|
||||
const allowedFields = Array.isArray(policy.allowed_fields)
|
||||
? policy.allowed_fields.filter(
|
||||
field => typeof field === 'string' && field
|
||||
)
|
||||
: []
|
||||
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
|
||||
? policy.max_rows_per_source
|
||||
: 10000
|
||||
if (typeof table !== 'string' || !table) return null
|
||||
if (typeof sourceTable !== 'string' || !sourceTable) return null
|
||||
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
|
||||
return {
|
||||
table,
|
||||
sourceTable,
|
||||
sourceIdField,
|
||||
allowedFields,
|
||||
maxRowsPerSource
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function permissionDisplayItem(permissions, extensions, translateFn) {
|
||||
const permission = permissions[0]
|
||||
const isReadWriteStorage =
|
||||
@@ -212,6 +245,7 @@
|
||||
badges: [],
|
||||
descriptions,
|
||||
fieldGroups: [],
|
||||
appendPolicies: [],
|
||||
invoicePolicies: [],
|
||||
extensionAccess: [],
|
||||
httpHosts: []
|
||||
@@ -244,6 +278,15 @@
|
||||
item.invoicePolicies = publicInvoicePolicies(permission)
|
||||
}
|
||||
|
||||
if (permission.id === 'ext.storage.append_public') {
|
||||
item.appendPolicies = publicAppendPolicies(permission)
|
||||
item.badges = item.appendPolicies.map(policy => ({
|
||||
key:
|
||||
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
|
||||
label: policy.table
|
||||
}))
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -327,6 +370,9 @@
|
||||
publicInvoicePolicySentence(policy) {
|
||||
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
||||
},
|
||||
publicAppendPolicySentence(policy) {
|
||||
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
|
||||
},
|
||||
permissionAccessLabel(access) {
|
||||
const key = `extension_permission_access_${access}`
|
||||
const label = this.$t(key)
|
||||
|
||||
@@ -67,6 +67,35 @@
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="permission.appendPolicies.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="
|
||||
$t('extension_permission_ext_storage_append_public_sources')
|
||||
"
|
||||
></div>
|
||||
<ul class="q-my-sm q-pl-md">
|
||||
<li
|
||||
v-for="policy of permission.appendPolicies"
|
||||
:key="
|
||||
policy.table +
|
||||
':' +
|
||||
policy.sourceTable +
|
||||
':' +
|
||||
policy.sourceIdField
|
||||
"
|
||||
>
|
||||
<span v-text="publicAppendPolicySentence(policy)"></span>
|
||||
<ul v-if="policy.allowedFields.length" class="q-pl-md">
|
||||
<li
|
||||
v-for="field of policy.allowedFields"
|
||||
:key="policy.table + ':' + field"
|
||||
v-text="field"
|
||||
></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
|
||||
@@ -10,6 +10,7 @@ from lnbits.core.wasm_ext.api.models import (
|
||||
CreateInvoicePublicRequest,
|
||||
EmptyRequest,
|
||||
PayInvoiceRequest,
|
||||
StorageAppendPublicRequest,
|
||||
StorageGetRequest,
|
||||
WalletBalanceRequest,
|
||||
)
|
||||
@@ -77,6 +78,136 @@ async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_public_append_uses_source_owner_and_allowed_fields(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
owner_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
count_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_count_rows",
|
||||
mocker.AsyncMock(return_value=0),
|
||||
)
|
||||
append_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
|
||||
mocker.AsyncMock(return_value="message-1"),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["name", "message"],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = await api.storage_append_public(
|
||||
StorageAppendPublicRequest(
|
||||
table="messages",
|
||||
source_id="thread-1",
|
||||
data={"name": "Alice", "message": "Hello"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.id == "message-1"
|
||||
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
|
||||
count_mock.assert_awaited_once_with(
|
||||
"demoext",
|
||||
"messages",
|
||||
{"thread_id": "thread-1"},
|
||||
owner_id="owner-1",
|
||||
)
|
||||
append_mock.assert_awaited_once_with(
|
||||
"demoext",
|
||||
"messages",
|
||||
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
|
||||
"owner-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_public_append_rejects_disallowed_fields(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="disallowed fields"):
|
||||
await api.storage_append_public(
|
||||
StorageAppendPublicRequest(
|
||||
table="messages",
|
||||
source_id="thread-1",
|
||||
data={"message": "Hello", "admin": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_count_rows",
|
||||
mocker.AsyncMock(return_value=1),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="limit reached"):
|
||||
await api.storage_append_public(
|
||||
StorageAppendPublicRequest(
|
||||
table="messages",
|
||||
source_id="thread-1",
|
||||
data={"message": "Hello"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -102,6 +102,104 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.append_public",
|
||||
"description": "Append public messages.",
|
||||
"policies": [
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["name", "message"],
|
||||
"max_rows_per_source": 100,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 50,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
assert permissions == [
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
description="Append public messages.",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 50,
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.append_public",
|
||||
"policies": [
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message"],
|
||||
"max_rows_per_source": 100,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.append_public",
|
||||
policies=[
|
||||
{
|
||||
"table": "messages",
|
||||
"source_table": "threads",
|
||||
"source_id_field": "thread_id",
|
||||
"allowed_fields": ["message", "admin"],
|
||||
"max_rows_per_source": 101,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
|
||||
@@ -22,6 +22,8 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
|
||||
from lnbits.core.wasm_ext.storage.crud import (
|
||||
OWNER_ID_FIELD,
|
||||
migrate_wasm_extension_database,
|
||||
storage_append_public_row,
|
||||
storage_count_rows,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
@@ -295,6 +297,59 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
||||
assert deleted is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
_write_storage_extension(settings, ext_id)
|
||||
|
||||
await migrate_wasm_extension_database(make_installable_extension(ext_id))
|
||||
await storage_set_row(
|
||||
ext_id,
|
||||
"threads",
|
||||
{"id": "thread-1", "title": "Support"},
|
||||
"owner-1",
|
||||
)
|
||||
message_id = await storage_append_public_row(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1", "message": "Hello"},
|
||||
"owner-1",
|
||||
)
|
||||
owner_count = await storage_count_rows(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1"},
|
||||
owner_id="owner-1",
|
||||
)
|
||||
other_owner_count = await storage_count_rows(
|
||||
ext_id,
|
||||
"messages",
|
||||
{"thread_id": "thread-1"},
|
||||
owner_id="owner-2",
|
||||
)
|
||||
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert message_id
|
||||
assert owner_count == 1
|
||||
assert other_owner_count == 0
|
||||
assert message is not None
|
||||
assert message["id"] == message_id
|
||||
assert message["thread_id"] == "thread-1"
|
||||
assert message["message"] == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
||||
tmp_path: Path,
|
||||
@@ -389,7 +444,20 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
{"name": "tags", "type": "string", "list": True},
|
||||
{"name": "created_at", "type": "datetime"},
|
||||
]
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "title", "type": "string"},
|
||||
]
|
||||
},
|
||||
"messages": {
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "thread_id", "type": "string"},
|
||||
{"name": "message", "type": "string"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
migration = {
|
||||
@@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
"op": "create_table",
|
||||
"table": "notes",
|
||||
"fields": schema["tables"]["notes"]["fields"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "create_table",
|
||||
"table": "threads",
|
||||
"fields": schema["tables"]["threads"]["fields"],
|
||||
},
|
||||
{
|
||||
"op": "create_table",
|
||||
"table": "messages",
|
||||
"fields": schema["tables"]["messages"]["fields"],
|
||||
},
|
||||
]
|
||||
}
|
||||
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user