mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(napplet): permissions management screen
A modern Material3 screen to review and revoke the permissions napplets hold. Data layer (commons, tested): NappletPermissionStore gains all() (enumerate persisted grants by coordinate) and remove(coordinate, capability); the ledger gains allPersistedGrants() and revoke(identity, capability). DataStore actual implements both (capability is the final space-delimited token of each key). UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet — resolved title + author, and a row per capability with an icon, label, and a control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked" indicator for per-use ones (payments only ever persist a DENY). Each row has a revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a shield. Reads/writes the same DataStore the broker uses, so changes take effect immediately. Reached via a "Manage permissions" action on the Napplets top bar (Route.NappletPermissions). commons ledger tests added for allPersistedGrants + single-capability revoke. :commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
BIN
Binary file not shown.
@@ -144,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletPermissionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
|
||||
@@ -290,6 +291,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -89,6 +89,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Napplets : Route()
|
||||
|
||||
@Serializable object NappletPermissions : Route()
|
||||
|
||||
@Serializable data class SoftwareAppDetail(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** One napplet's persisted permission grants, ready to render. */
|
||||
private data class NappletGrantsUi(
|
||||
val identity: NappletIdentity,
|
||||
val title: String,
|
||||
val capabilities: List<Pair<NappletCapability, GrantState>>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NappletPermissionsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) }
|
||||
|
||||
var items by remember { mutableStateOf<List<NappletGrantsUi>?>(null) }
|
||||
var reload by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(reload) {
|
||||
items = withContext(Dispatchers.Default) { loadGrants(ledger) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun mutate(block: suspend () -> Unit) {
|
||||
scope.launch {
|
||||
block()
|
||||
reload++
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions), nav) },
|
||||
) { padding ->
|
||||
val current = items
|
||||
when {
|
||||
current == null ->
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
current.isEmpty() -> EmptyState(Modifier.fillMaxSize().padding(padding))
|
||||
|
||||
else ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(current, key = { it.identity.coordinate }) { napplet ->
|
||||
NappletPermissionCard(
|
||||
napplet = napplet,
|
||||
onSetAllowed = { cap, allowed ->
|
||||
mutate { ledger.record(napplet.identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) }
|
||||
},
|
||||
onRevoke = { cap -> mutate { ledger.revoke(napplet.identity, cap) } },
|
||||
onForget = { mutate { ledger.revokeAll(napplet.identity) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(modifier: Modifier) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_empty),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NappletPermissionCard(
|
||||
napplet: NappletGrantsUi,
|
||||
onSetAllowed: (NappletCapability, Boolean) -> Unit,
|
||||
onRevoke: (NappletCapability) -> Unit,
|
||||
onForget: () -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
MaterialSymbols.Apps,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
napplet.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
napplet.identity.authorPubKey.take(12) + "…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
napplet.capabilities.forEach { (cap, grant) ->
|
||||
CapabilityRow(
|
||||
capability = cap,
|
||||
grant = grant,
|
||||
onSetAllowed = { onSetAllowed(cap, it) },
|
||||
onRevoke = { onRevoke(cap) },
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onForget,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(stringResource(R.string.napplet_permissions_forget))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapabilityRow(
|
||||
capability: NappletCapability,
|
||||
grant: GrantState,
|
||||
onSetAllowed: (Boolean) -> Unit,
|
||||
onRevoke: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
capability.symbol(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(capability.label(), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
capability.description(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (capability.requiresPerUseConsent) {
|
||||
// Payments only ever persist a DENY; the user can clear it to allow per-payment prompts again.
|
||||
Text(
|
||||
stringResource(R.string.napplet_permissions_blocked),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Switch(
|
||||
checked = grant == GrantState.ALLOW_ALWAYS,
|
||||
onCheckedChange = onSetAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
IconButton(onClick = onRevoke) {
|
||||
Icon(
|
||||
MaterialSymbols.Block,
|
||||
contentDescription = stringResource(R.string.napplet_permissions_revoke),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadGrants(ledger: NappletPermissionLedger): List<NappletGrantsUi> =
|
||||
ledger
|
||||
.allPersistedGrants()
|
||||
.map { (coordinate, caps) ->
|
||||
val author = coordinate.substringBefore(':')
|
||||
val identifier = coordinate.substringAfter(':', "")
|
||||
NappletGrantsUi(
|
||||
identity = NappletIdentity(authorPubKey = author, identifier = identifier),
|
||||
title = resolveTitle(author, identifier),
|
||||
capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value },
|
||||
)
|
||||
}.sortedBy { it.title.lowercase() }
|
||||
|
||||
/** Best-effort human title from a cached manifest; falls back to the d-identifier. */
|
||||
private fun resolveTitle(
|
||||
author: String,
|
||||
identifier: String,
|
||||
): String {
|
||||
val events =
|
||||
Amethyst.instance.cache
|
||||
.filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author)))
|
||||
.mapNotNull { it.event }
|
||||
val match =
|
||||
events.firstOrNull { ev ->
|
||||
when (ev) {
|
||||
is NamedNappletEvent -> ev.identifier() == identifier
|
||||
is RootNappletEvent -> identifier.isEmpty()
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
return (match as? NappletManifest)?.title()?.ifBlank { null }
|
||||
?: identifier.ifBlank { "Napplet" }
|
||||
}
|
||||
|
||||
private fun NappletCapability.label(): String =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> "Identity"
|
||||
NappletCapability.RELAY -> "Relays"
|
||||
NappletCapability.WALLET -> "Wallet"
|
||||
NappletCapability.STORAGE -> "Storage"
|
||||
NappletCapability.NET -> "Network"
|
||||
}
|
||||
|
||||
private fun NappletCapability.description(): String =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> "Sign and encrypt as you"
|
||||
NappletCapability.RELAY -> "Read and publish your events"
|
||||
NappletCapability.WALLET -> "Pay Lightning invoices"
|
||||
NappletCapability.STORAGE -> "Its own private storage"
|
||||
NappletCapability.NET -> "Direct network access"
|
||||
}
|
||||
|
||||
private fun NappletCapability.symbol(): MaterialSymbol =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> MaterialSymbols.Key
|
||||
NappletCapability.RELAY -> MaterialSymbols.Public
|
||||
NappletCapability.WALLET -> MaterialSymbols.Bolt
|
||||
NappletCapability.STORAGE -> MaterialSymbols.Storage
|
||||
NappletCapability.NET -> MaterialSymbols.Language
|
||||
}
|
||||
+17
-2
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -45,9 +46,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLauncher
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -78,7 +83,17 @@ fun NappletsScreen(
|
||||
}.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringResource(R.string.napplets), nav) },
|
||||
topBar = {
|
||||
MyExtensibleTopAppBar(
|
||||
title = { Text(stringResource(R.string.napplets)) },
|
||||
navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } },
|
||||
actions = {
|
||||
IconButton(onClick = { nav.nav(Route.NappletPermissions) }) {
|
||||
Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (napplets.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -734,6 +734,13 @@
|
||||
<string name="software_apps">Apps</string>
|
||||
<string name="route_software_apps">Apps</string>
|
||||
<string name="napplets">Napplets</string>
|
||||
<string name="napplet_permissions">Napplet permissions</string>
|
||||
<string name="napplet_manage_permissions">Manage permissions</string>
|
||||
<string name="napplet_permissions_empty">No napplet permissions yet</string>
|
||||
<string name="napplet_permissions_empty_subtitle">Permissions you grant to napplets will appear here.</string>
|
||||
<string name="napplet_permissions_forget">Forget this napplet</string>
|
||||
<string name="napplet_permissions_blocked">Blocked</string>
|
||||
<string name="napplet_permissions_revoke">Revoke</string>
|
||||
<string name="nip82_repository_label">Source: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Download</string>
|
||||
|
||||
+12
@@ -88,6 +88,18 @@ class NappletPermissionLedger(
|
||||
}
|
||||
}
|
||||
|
||||
/** All persisted standing grants, keyed by coordinate — for the permissions-management UI. */
|
||||
suspend fun allPersistedGrants(): Map<String, Map<NappletCapability, GrantState>> = store.all()
|
||||
|
||||
/** Forgets a single capability grant for [identity] — both persisted and in-session. */
|
||||
suspend fun revoke(
|
||||
identity: NappletIdentity,
|
||||
capability: NappletCapability,
|
||||
) {
|
||||
lock.withLock { session[identity.coordinate]?.remove(capability) }
|
||||
store.remove(identity.coordinate, capability)
|
||||
}
|
||||
|
||||
/** Forgets all grants for [identity] — both the persisted and the in-session ones. */
|
||||
suspend fun revokeAll(identity: NappletIdentity) {
|
||||
lock.withLock { session.remove(identity.coordinate) }
|
||||
|
||||
+19
@@ -41,6 +41,15 @@ interface NappletPermissionStore {
|
||||
)
|
||||
|
||||
suspend fun clear(coordinate: String)
|
||||
|
||||
/** All persisted grants, keyed by coordinate — used by the permissions-management UI. */
|
||||
suspend fun all(): Map<String, Map<NappletCapability, GrantState>>
|
||||
|
||||
/** Removes a single persisted grant (revoking one capability for one napplet). */
|
||||
suspend fun remove(
|
||||
coordinate: String,
|
||||
capability: NappletCapability,
|
||||
)
|
||||
}
|
||||
|
||||
/** A thread-safe in-memory [NappletPermissionStore] for tests and ephemeral sessions. */
|
||||
@@ -66,4 +75,14 @@ class InMemoryNappletPermissionStore : NappletPermissionStore {
|
||||
data.remove(coordinate)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> = lock.withLock { data.mapValues { it.value.toMap() } }
|
||||
|
||||
override suspend fun remove(
|
||||
coordinate: String,
|
||||
capability: NappletCapability,
|
||||
) = lock.withLock {
|
||||
data[coordinate]?.remove(capability)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -108,6 +108,36 @@ class NappletPermissionLedgerTest {
|
||||
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allPersistedGrantsListsPersistedDecisionsByCoordinate() =
|
||||
runTest {
|
||||
val ledger = ledger()
|
||||
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
|
||||
ledger.record(applet, NappletCapability.RELAY, GrantState.DENY)
|
||||
ledger.record(applet, NappletCapability.STORAGE, GrantState.ALLOW_SESSION) // not persisted
|
||||
ledger.record(other, NappletCapability.WALLET, GrantState.DENY)
|
||||
|
||||
val all = ledger.allPersistedGrants()
|
||||
assertEquals(
|
||||
mapOf(NappletCapability.IDENTITY to GrantState.ALLOW_ALWAYS, NappletCapability.RELAY to GrantState.DENY),
|
||||
all[applet.coordinate],
|
||||
)
|
||||
assertEquals(mapOf(NappletCapability.WALLET to GrantState.DENY), all[other.coordinate])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun revokeForgetsOnlyThatCapability() =
|
||||
runTest {
|
||||
val ledger = ledger()
|
||||
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
|
||||
ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_ALWAYS)
|
||||
|
||||
ledger.revoke(applet, NappletCapability.IDENTITY)
|
||||
|
||||
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY))
|
||||
assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.RELAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregateHashDoesNotAffectTheLedgerKey() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user