diff --git a/docs/specs/delegation.md b/docs/specs/delegation.md new file mode 100644 index 0000000000..f75ebf78d0 --- /dev/null +++ b/docs/specs/delegation.md @@ -0,0 +1,110 @@ +# Fabric Identities + +This document addresses future plans for identity and permissions for the Common Tools fabric, and the process to get there. + +## URL structure + +Via latest [PRD](https://docs.google.com/document/d/1KixOc7L5LZ8IdJtO_9pNHohPg_LXEF5hFgoAzvq9KLc/edit?tab=t.vx0btmvvbit9), the URL structure represents the three address component types: + +``` +DID = "did:key:{string}" +DOMAIN = "{string.}*{string}.{string}" +CHARM_ID = "of:${HASH}" +NAMESPACE = DID | DOMAIN +SPACE = DID | string; +CHARM = CHARM_ID | string; +ADDR = "/{@NAMESPACE/}?SPACE/CHARM?" +``` + +Each component may be a slug/name ("my-notes-123") or a DID key ("did:key:abc.."). +Namespaces are optional in the URL, and are prefixed with a "@" followed by either +a DID key or DNS address. If namespace not provided, then an implicit, global provider-namespace is used. + +## Namespace + +Namespaces are a scope of spaces, and spaces contain charms. + +Namespaces can be referenced via DNS record, resolving to a DID key, or via directly as a DID key. Similarly, a provider-namespace is implied when no namespace given, which also resolves to an identity DID. + +Namespaces manage many spaces, and are responsible from mapping space petnames to their identity. +Each namespace has an "admin" space (possibly with the same identity as the namespace itself?), where records are stored. + +> [!NOTE] +> There may be a way to view a namespace, like itemizing all contained spaces, but is out of scope here. + +Additionally, delegations for access to the namespace is stored in the admin space as well. See **CAPABILITIES** below for delegation. + +### Namespace Admin Space + +``` +DELEGATIONS = UCAN[]; +// Mapping of space name to space identity +ITEMS = Map +``` + +### Provider Namespace + +The provider namespace is local to a provider service, and the default namespace used when none supplied. This functions like other user-owned namespaces, except owned by the provider. + +## Space + +Each space contains many charms and other data. A space is the root of permissions in the system, and capabilities are applied per space. Similar to namespaces, each space must maintain a mapping of charm names to identities, as well as permission for the space. Unlike namespaces, this data is stored in well-known Cells rather than a derivable space. + +### Space Admin Space + +``` +DELEGATIONS = UCAN[]; +// Mapping of charm name to charm identity +ITEMS = Map +``` + +## Capabilities + +Access and permissions are handled via an authorization token like [UCAN](https://github.com/ucan-wg/spec) or another alternative. These tokens represent capabilities delegated to identities for a given subject. + +* `SPACE:CREATE`: A namespace-based capability indicating permission to create a new space. This is the only delegation stored in a namespace admin record. +* `SPACE:READ`: Permission to read a space. +* `SPACE:WRITE`: Permission to write data to a space. A superset of `SPACE:READ`. +* `SPACE:OWNER`: Permission to modify authorization in a space. A superset of `SPACE:WRITE`. + +> [!NOTE] +> Revocations and rotations are currently out of scope, but could be handled with delegation. + +### The `ANYONE` User + +We use a well-known key to assign delegation to all users. + +### Example + +Alice (`did:key:alice`) creates a new space on `provider.com` (with identity `did:key:provider`) with the name `alice-space`. No namespace was provided, so the *provider namespace* is used. The space name with identity `did:key:provider` (possibly a derivation of identity) is referenced to ensure Alice has permission to create a new space, and that `alice-space` is an available space name. + +`provider.com` was configured to allow all users to create a new space by delegating the `SPACE:CREATE` capability to the `ANYONE` identity, stored in the `did:key:provider` space's delegations. Additionally, there are no current spaces with the name `alice-space`. The provider creates the space on Alice's behalf, generating a new key for the space (`did:key:alice-space`). This key is stored by the provider, immediately delegating `SPACE:OWNER` capabilities to `did:key:alice`. + +Now, `provider.com` is hosting `alice-space` in the provider namespace. No one else yet has any access to data stored in this space. Alice wants to invite Bob (`did:key:bob`) to this space, and signs a new delegation with `did:key:alice` for `did:key:bob`, granting Bob `SPACE:WRITE` permissions. Bob can now read and write data to the space, but cannot invite Eve, lacking the `SPACE:OWNER` capability. + +Similarly, in a non-provider namespace (e.g. `@alice.fab.com`, or `@did:key:alice`), the namespace is created, immediately assigning `SPACE:CREATE` capabilities to `did:key:alice`, or whatever key `@alice.fab.com` resolves to. No other identities may create spaces within Alice's namespace, unless they were to e.g. delegate `SPACE:CREATE` capabilities to another user. + +## Plan + +Currently, `Session`s are created in the workspace, and the abstraction between public/private spaces needs updated. There are a few areas currently that will need to use a new Session compatible with this document: + +### Manual Private Space + +* charm/src/ops/charms-controller.ts +* shell/src/lib/runtime.ts +* cli/lib/charm.ts + +### Uses Admin Session + +* background-charm-service/src/worker.ts +* background-charm-service/cast-admin.ts + +### Steps + +* Update Session interface with this address proposal, apply to codebase +* Sign transactions with correct identity, not the anonymous user. +* Store delegations when creating a new space. +* Verify transactions against the signer and space's delegations. +* [UX] Option to create new space as public-write (all users have write access(?)) public (delegating read permissions to all users, write permissions to owner), or private (only delegates read/write access to owner) + + diff --git a/packages/identity/src/url.ts b/packages/identity/src/url.ts new file mode 100644 index 0000000000..d0f02babca --- /dev/null +++ b/packages/identity/src/url.ts @@ -0,0 +1,65 @@ +export type Domain = string; // `fab.space`, `alice.fab.space` +export type DidKey = `did:key:${string}`; +export type Namespace = Domain | DidKey; +export type SpaceName = string | DidKey; +export type CharmName = string | DidKey; + +export type AddrComponent = { + name: string; + did: undefined; +} | { + name: undefined; + did: DidKey; +}; + +export type CharmAddress = { + namespace?: AddrComponent; + space?: AddrComponent; + charm?: AddrComponent; +}; + +class CharmAddressError extends Error { + constructor(message?: string) { + super(message ?? "Invalid address."); + } +} + +export function parseCharmAddress(url: URL): CharmAddress { + const [prefix, ns, space, charm] = url.pathname.split("/"); + if (prefix || !ns) throw new CharmAddressError(); + if (!space && charm) throw new CharmAddressError(); + // First component has `@`, this is a namespace + if (ns.startsWith("@")) { + return { + namespace: newAddrComponent(ns.substring(1)), + space: space ? newAddrComponent(space) : undefined, + charm: charm ? newAddrComponent(charm) : undefined, + }; + } + // First component does not have `@`, this is a shared + // space global to the provider. + return { + namespace: undefined, + space: newAddrComponent(ns), + charm: space ? newAddrComponent(space) : undefined, + }; +} + +export function parseCharmAddressFromString(url: string): CharmAddress { + return parseCharmAddress(new URL(url)); +} + +export function isDidKey(value: unknown): value is DidKey { + return typeof value === "string" && value.startsWith("did:key:") && + value.length === 56; +} + +export function newAddrComponent(value: unknown) { + if (typeof value !== "string" || value === "") throw new CharmAddressError(); + if (isDidKey(value)) { + return { did: value, name: undefined }; + } + // Throw if valid resembles a did:key: without being one + if (value.startsWith("did:key:")) throw new CharmAddressError(); + return { did: undefined, name: value }; +} diff --git a/packages/identity/test/url.test.ts b/packages/identity/test/url.test.ts new file mode 100644 index 0000000000..7a31482097 --- /dev/null +++ b/packages/identity/test/url.test.ts @@ -0,0 +1,122 @@ +import { assertObjectMatch, assertThrows } from "@std/assert"; +import { CharmAddress, DidKey, parseCharmAddress } from "../src/url.ts"; + +const BASE = new URL("http://foo.com"); +const SPACE_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsSPACE"; +const CHARM_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsCHARM"; +const ALICE_KEY = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsALICE"; + +const VALID: [string, CharmAddress][] = [ + ["/globalspace", { + namespace: undefined, + space: name("globalspace"), + charm: undefined, + }], + ["/globalspace/charm", { + namespace: undefined, + space: name("globalspace"), + charm: name("charm"), + }], + ["/globalspace/charm", { + namespace: undefined, + space: name("globalspace"), + charm: name("charm"), + }], + [`/${SPACE_KEY}/charm`, { + namespace: undefined, + space: did(SPACE_KEY), + charm: name("charm"), + }], + [`/${SPACE_KEY}/${CHARM_KEY}`, { + namespace: undefined, + space: did(SPACE_KEY), + charm: did(CHARM_KEY), + }], + [`/globalspace/${CHARM_KEY}`, { + namespace: undefined, + space: name("globalspace"), + charm: did(CHARM_KEY), + }], + [`/${SPACE_KEY}`, { + namespace: undefined, + space: did(SPACE_KEY), + charm: undefined, + }], + [`/@${ALICE_KEY}`, { + namespace: did(ALICE_KEY), + space: undefined, + charm: undefined, + }], + [`/@${ALICE_KEY}/${SPACE_KEY}`, { + namespace: did(ALICE_KEY), + space: did(SPACE_KEY), + charm: undefined, + }], + [`/@${ALICE_KEY}/${SPACE_KEY}/${CHARM_KEY}`, { + namespace: did(ALICE_KEY), + space: did(SPACE_KEY), + charm: did(CHARM_KEY), + }], + [`/@${ALICE_KEY}/space/${CHARM_KEY}`, { + namespace: did(ALICE_KEY), + space: name("space"), + charm: did(CHARM_KEY), + }], + [`/@${ALICE_KEY}/${SPACE_KEY}/charm`, { + namespace: did(ALICE_KEY), + space: did(SPACE_KEY), + charm: name("charm"), + }], + [`/@alice.fab.com/${SPACE_KEY}/charm`, { + namespace: name("alice.fab.com"), + space: did(SPACE_KEY), + charm: name("charm"), + }], + ["/@namespace/space/", { + namespace: name("namespace"), + space: name("space"), + charm: undefined, + }], +]; + +const INVALID: string[] = [ + "/", // Empty pathname - no first component + "", // Empty string + "//space", // Empty first component + "/space//charm", // Empty second component + "/@namespace//charm", // Empty space component with namespace + "/@", // Namespace marker only, no value + "/@/space", // Empty namespace after @ + "/did:key:", // Malformed DID - no value after did:key: + "/@did:key:/space", // Empty DID in namespace + "/space/did:key:", // Empty DID in charm +]; + +Deno.test("Parses correct CharmAddress", () => { + for (const [url, expectation] of VALID) { + assertObjectMatch( + parseCharmAddress(new URL(url, BASE)), + expectation, + `"${url}" is a valid address.`, + ); + } +}); + +Deno.test("Throws error on invalid CharmAddress", () => { + for (const url of INVALID) { + assertThrows( + () => parseCharmAddress(new URL(url, BASE)), + Error, + "Invalid address", + `"${url}" is an invalid address.`, + ); + } +}); + +function did(value: DidKey) { + return { did: value, name: undefined }; +} + +function name(value: string) { + return { did: undefined, name: value }; +}