feat: permissions
This commit is contained in:
+2
-2
@@ -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()
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ class InMemoryExtensionAPI(ExtensionAPI):
|
||||
unit=request.currency or "sat",
|
||||
memo=request.memo,
|
||||
extra=request.extra,
|
||||
extension=self.extension_id,
|
||||
),
|
||||
)
|
||||
return CreateInvoiceResponse(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 '[]'"
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
},
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user