feat: permissions

This commit is contained in:
Vlad Stan
2026-07-09 16:33:09 +03:00
parent 2bf50968ba
commit 2952e41fd3
9 changed files with 266 additions and 55 deletions
+2 -2
View File
@@ -117,7 +117,7 @@ async def startup(app: FastAPI):
create_llms_txt_route(app)
# initialize tasks
register_async_tasks()
register_async_tasks(app)
enqueue_admin_notification(
NotificationType.server_start_stop,
@@ -486,7 +486,7 @@ async def check_and_register_extensions(app: FastAPI) -> None:
await update_installed_extension_state(ext_id=ext.code, active=False)
def register_async_tasks() -> None:
def register_async_tasks(app: FastAPI) -> None:
task_manager.init()
+1
View File
@@ -113,6 +113,7 @@ class InMemoryExtensionAPI(ExtensionAPI):
unit=request.currency or "sat",
memo=request.memo,
extra=request.extra,
extension=self.extension_id,
),
)
return CreateInvoiceResponse(
+8 -7
View File
@@ -9,6 +9,8 @@ from typing import Any
from fastapi import FastAPI
from lnbits.core.crud.extensions import get_installed_extension
from .api import list_extension_api_methods
from .loader import WasmExtension, register_wasm_extension
from .models import UserWalletSummary
@@ -27,7 +29,7 @@ async def invoke_wasm_extension_export(
extension = _get_registered_extension(app, ext_id)
state = _get_extension_state(app)
state.user_wallets[extension.id] = _user_wallet_summaries(user)
permissions = _extension_permissions(extension)
permissions = await _extension_permissions(extension)
api = InMemoryExtensionAPI(
extension.id,
permissions,
@@ -204,12 +206,11 @@ def _get_extension_state(app: FastAPI) -> InMemoryExtensionState:
return state
def _extension_permissions(extension: WasmExtension) -> set[str]:
permissions = set()
for permission in extension.config.get("permissions") or []:
if isinstance(permission, Mapping) and isinstance(permission.get("id"), str):
permissions.add(permission["id"])
return permissions
async def _extension_permissions(extension: WasmExtension) -> set[str]:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return set()
return {permission.id for permission in installed_extension.permissions}
def _user_id(user: Any | None) -> str | None:
+9
View File
@@ -815,3 +815,12 @@ async def m045_add_external_id_to_payments(db: Connection):
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
async def m046_add_permissions_to_installed_extensions(db: Connection):
"""
Adds granted permissions to installed extensions.
"""
await db.execute(
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
)
+16
View File
@@ -77,6 +77,13 @@ class GitHubRepo(BaseModel):
default_branch: str
class ExtensionPermission(BaseModel):
id: str
label: str | None = None
description: str | None = None
policy: dict[str, Any] | None = None
class ExtensionConfig(BaseModel):
name: str
short_description: str
@@ -84,6 +91,8 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
extension_type: str | None = None
permissions: list[ExtensionPermission] = []
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -350,6 +359,7 @@ class InstallableExtension(BaseModel):
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
permissions: list[ExtensionPermission] = []
@property
def hash(self) -> str:
@@ -624,6 +634,11 @@ class InstallableExtension(BaseModel):
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
permissions=[
ExtensionPermission.parse_obj(permission)
for permission in config_json.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
],
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
@@ -817,6 +832,7 @@ class CreateExtension(BaseModel):
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
permissions: list[ExtensionPermission] = []
class ExtensionDetailsRequest(BaseModel):
+75 -2
View File
@@ -1,5 +1,9 @@
import asyncio
import importlib
import json
import zipfile
from pathlib import PurePosixPath
from typing import Any
from loguru import logger
@@ -20,11 +24,18 @@ from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
from ..models.extensions import (
Extension,
ExtensionMeta,
ExtensionPermission,
InstallableExtension,
)
async def install_extension(
ext_info: InstallableExtension, skip_download: bool | None = False
ext_info: InstallableExtension,
skip_download: bool | None = False,
granted_permissions: list[ExtensionPermission] | None = None,
) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
@@ -44,6 +55,11 @@ async def install_extension(
if not skip_download:
await ext_info.download_archive()
extension_config = _load_extension_archive_config(ext_info)
ext_info.permissions = _validate_extension_permissions(
ext_info, granted_permissions, extension_config
)
ext_info.extract_archive()
db_version = await get_db_version(ext_info.id)
@@ -66,6 +82,63 @@ async def install_extension(
return extension
def _validate_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any],
) -> list[ExtensionPermission]:
if extension_config.get("extension_type") != "wasm":
return []
requested_permissions = [
ExtensionPermission.parse_obj(permission)
for permission in extension_config.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
]
if not requested_permissions:
return []
if granted_permissions is None:
raise ValueError(
f"WASM extension '{ext_info.id}' requires permission approval."
)
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
raise ValueError(
f"WASM extension '{ext_info.id}' was not granted all requested "
"permissions."
)
return requested_permissions
def _load_extension_archive_config(ext_info: InstallableExtension) -> dict[str, Any]:
if not ext_info.zip_path.is_file():
return {}
try:
with zipfile.ZipFile(ext_info.zip_path, "r") as archive:
config_name = _archive_config_name(archive.namelist())
if not config_name:
return {}
with archive.open(config_name) as config_file:
config = json.load(config_file)
except Exception as exc:
raise ValueError(f"Cannot read extension config for '{ext_info.id}'.") from exc
return config if isinstance(config, dict) else {}
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
+3
View File
@@ -1,5 +1,8 @@
import asyncio
import json
from typing import Any
from fastapi import FastAPI
from loguru import logger
from lnbits.core.crud import create_audit_entry
+56 -43
View File
@@ -89,7 +89,9 @@ async def api_install_extension(data: CreateExtension):
)
try:
extension = await install_extension(ext_info)
extension = await install_extension(
ext_info, granted_permissions=data.permissions
)
except Exception as exc:
logger.warning(exc)
@@ -463,6 +465,8 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
"extension_type": config.extension_type,
"permissions": [dict(permission) for permission in config.permissions],
}
except Exception as exc:
raise HTTPException(
@@ -535,9 +539,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
installed_exts_by_id = {e.id: e for e in installed_exts}
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
installed_ext = installed_exts_by_id.get(e.id)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
@@ -558,47 +563,55 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
extension_data = []
for ext in installable_exts:
installed_ext = installed_exts_by_id.get(ext.id)
extension_data.append(
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"isWasm": installed_ext.is_wasm if installed_ext else ext.is_wasm,
"permissions": (
[dict(permission) for permission in installed_ext.permissions]
if installed_ext
else []
),
"inProgress": False,
"selectedForUpdate": False,
}
)
return extension_data
+96 -1
View File
@@ -132,6 +132,12 @@ window.PageExtensions = {
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const grantedPermissions =
await this.resolveExtensionPermissionGrant(release)
if (grantedPermissions === null) {
return
}
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
release.payment_hash =
@@ -143,7 +149,8 @@ window.PageExtensions = {
archive: release.archive,
source_repo: release.source_repo,
payment_hash: release.payment_hash,
version: release.version
version: release.version,
permissions: grantedPermissions
})
.then(response => {
this.selectedExtension.inProgress = false
@@ -406,6 +413,10 @@ window.PageExtensions = {
},
async payAndInstall(release) {
try {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
return
}
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
const paymentInfo = await this.requestPaymentForInstall(
@@ -451,6 +462,10 @@ window.PageExtensions = {
}
},
async showInstallQRCode(release) {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
return
}
this.selectedRelease = release
try {
@@ -628,6 +643,8 @@ window.PageExtensions = {
release.is_version_compatible = data.is_version_compatible
release.min_lnbits_version = data.min_lnbits_version
release.warning = data.warning
release.extension_type = data.extension_type
release.permissions = data.permissions || []
} catch (error) {
console.warn(error)
release.error = error
@@ -636,6 +653,77 @@ window.PageExtensions = {
release.inProgress = false
}
},
async resolveExtensionPermissionGrant(release) {
const permissions = this.extensionPermissionsForRelease(release)
if (
!this.releaseRequiresPermissionGrant(release) ||
!permissions.length
) {
return []
}
if (release.grantedPermissions) {
return release.grantedPermissions
}
const granted = await this.confirmExtensionPermissions(permissions)
if (!granted) {
return null
}
release.grantedPermissions = permissions
return permissions
},
extensionPermissionsForRelease(release) {
return release.permissions || this.selectedExtension?.permissions || []
},
releaseRequiresPermissionGrant(release) {
return (
release.extension_type === 'wasm' ||
this.selectedExtension?.isWasm === true
)
},
confirmExtensionPermissions(permissions) {
const permissionItems = permissions
.map(permission => {
const label = this.escapeHtml(permission.label || permission.id)
const description = permission.description
? `<div class="text-caption text-grey-7">${this.escapeHtml(
permission.description
)}</div>`
: ''
return `<li><strong>${label}</strong>${description}</li>`
})
.join('')
return new Promise(resolve => {
this.$q
.dialog({
title: 'Grant extension permissions',
message: `<p>This WASM extension requests these permissions:</p><ul>${permissionItems}</ul>`,
html: true,
persistent: true,
ok: {
label: 'Grant and install',
color: 'primary',
flat: true
},
cancel: {
label: this.$t('cancel'),
color: 'grey',
flat: true
}
})
.onOk(() => resolve(true))
.onCancel(() => resolve(false))
.onDismiss(() => resolve(false))
})
},
escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
},
async selectAllUpdatableExtensionss() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
},
@@ -646,6 +734,13 @@ window.PageExtensions = {
if (!ext.selectedForUpdate) {
continue
}
if (ext.isWasm) {
Quasar.Notify.create({
type: 'warning',
message: `Skipping ${ext.id}; WASM updates require permission approval.`
})
continue
}
ext.inProgress = true
await LNbits.api.request('POST', `/api/v1/extension`, null, {
ext_id: ext.id,