-
Notifications
You must be signed in to change notification settings - Fork 11
WIP: Draft up identity specs for delegation #2013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace “responsible from” with “responsible for” so the sentence reads correctly and the responsibility is unambiguous. Prompt for AI agents
Suggested change
|
||||||
| 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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the plural verb “are” so the subject-verb agreement is correct and the sentence reads naturally. Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| ### Namespace Admin Space | ||||||
|
|
||||||
| ``` | ||||||
| DELEGATIONS = UCAN[]; | ||||||
| // Mapping of space name to space identity | ||||||
| ITEMS = Map<string, DidKey> | ||||||
| ``` | ||||||
|
|
||||||
| ### 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<string, DidKey> | ||||||
| ``` | ||||||
|
|
||||||
| ## 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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1, interestingly i think this could just correspond to the ability to create URIs whose hash is just derived from the space did, not some other URI that's already in the space. (assumes we restrict this, which we currently do, but sometimes consider since it guarantees unforgeable identifiers) |
||||||
| * `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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there's a bunch in the PRD about provider-based names being disambiguated with an appended short hash, hopefully making them globally unique. maybe that happens as part of creating the key (see next paragraph) |
||||||
|
|
||||||
| `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`. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think that's implicit, but that delegation also removes did:key:alice-space from the owner list, right? i.e. if there is no ACL then the assumption is OWNER:, and the typical bootstrapping flow replaces that with others, taking the original DID out? |
||||||
|
|
||||||
| 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: | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can start with today's derived-from-name spaces for now and focus on adding ACLs. Effectively ~ partitions the namespaces per user, but we could just choose to not use that. In a way it's the most simple implementation of provider-level lookup and we can just grow it from there. |
||||||
|
|
||||||
| ### 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as noted above, it might be sufficient to go straight to the next step, at least to start. |
||||||
| * 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) | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("/"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parser drops any path segments beyond the first three address components, so a malformed URL such as "/@ns/space/charm/extra" is treated as valid instead of throwing. Please reject paths that contain additional segments after the charm component. Prompt for AI agents |
||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added a few comments there, but the most relevant is just to suggest that the top-level part must resolve to a space (either by being a DID, a DNS lookup or using the provider resolution), and then each subsequent component can be a lookup in that space that leads to another space, and so on.
i also added a comment there proposing that the default way to resolve components in a space is by deriving a URI from the slug and the space DID, which can then contain a link to the actual charm, or a space redirect. see below on how that maps to the CREATE capability.