diff --git a/SUMMARY.md b/SUMMARY.md index 43ee9f7..bad4a87 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -37,6 +37,7 @@ * [πŸ” Authentication](api-reference/authentication.md) * [βš™οΈ Labs API](api-reference/labs-api/README.md) * [Lab Management](api-reference/labs-api/lab-management.md) + * [Access Policies](api-reference/labs-api/access-policies.md) * [Files](api-reference/labs-api/files.md) * [Browse & Search](api-reference/labs-api/browse-and-search.md) * [Legal Agreements](api-reference/labs-api/legal-agreements.md) diff --git a/api-reference/authentication.md b/api-reference/authentication.md index 5218292..c3f6a9a 100644 --- a/api-reference/authentication.md +++ b/api-reference/authentication.md @@ -87,6 +87,8 @@ x-wallet-address: YOUR_WALLET_ADDRESS Either way, the caller still has to be authorized for the target lab β€” a Service Token carries its own lab scope, and a Privy session is checked against the wallet's onchain role (LabNFT owner, authorized multisig signer, or an active role on `AccessResolver`). Supplying neither returns a `NO_AUTH` error naming both paths. +> **Permissionless labs still authenticate.** A lab owner can open specific capabilities β€” contributing files, editing, deleting, announcing β€” to callers who hold no role, either unconditionally, until a deadline, or subject to an onchain condition. That removes the *membership* requirement, never the *identity* one: the caller still presents an API Key plus a Privy session or a Service Token, and any wallet can self-issue a Service Token via the [wallet-signature flow](labs-api/service-tokens.md#obtaining-tokens). See [Access Policies](labs-api/access-policies.md). + **Mutations accepting either path:** - `createLab` - Create a lab (data room) for an onchain lab (OCL) Β· πŸ’³ also available pay-per-call via [x402 Gateway](x402-gateway.md) @@ -95,10 +97,11 @@ Either way, the caller still has to be authorized for the target lab β€” a Servi - `updateFileMetadata` - Update file metadata - `deleteDataRoomFile` - Delete a file - `createAnnouncement` - Create an announcement Β· πŸ’³ also available pay-per-call via [x402 Gateway](x402-gateway.md) +- `updateLabAccessPolicy` - Set a lab's contribution-access policy (OCL admin only) - `updateLabNftMetadata` - Update LabNFT display metadata (OCL admin only) - `generateLabImageUploadUrl` - Get a presigned URL to upload a LabNFT image (OCL admin only) - `signLegalAgreement` - Record acceptance of a legal agreement -- `generateDataEncryptionKey` - Generate a standalone data encryption key Β· πŸ’³ also available pay-per-call via [x402 Gateway](x402-gateway.md) +- `generateDataEncryptionKey` - Generate a data encryption key for a lab file, bound to its access conditions Β· πŸ’³ also available pay-per-call via [x402 Gateway](x402-gateway.md) - `decryptDataKey` - Decrypt a file's data key for an authorized caller Β· πŸ’³ also available pay-per-call via [x402 Gateway](x402-gateway.md) **Service-Token-only mutations** β€” these manage token lifecycle and reject Privy sessions: diff --git a/api-reference/changelog.md b/api-reference/changelog.md index 3d05e26..28a457d 100644 --- a/api-reference/changelog.md +++ b/api-reference/changelog.md @@ -25,12 +25,80 @@ All Molecule APIs (Labs, Tokenization, and IPNFT (Deprecated) β€” they share one ## Labs API +### Lab access policies β€” permissionless & condition-gated labs + +Labs can now open individual data-room capabilities beyond their onchain members. This is **purely additive**: a lab created without a policy behaves exactly as before, including its error responses, and a role grant always wins over a policy, so no existing integration changes. + +#### New mutation + +| Mutation | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `updateLabAccessPolicy` | Set a lab's contribution-access policy β€” open it up, gate it back down (`preset: GATED`), or configure per-capability rules. Owner-only | + +#### New input field + +`CreateLabInput` gained an optional `accessPolicy: LabAccessPolicyInput`, which creates the lab already open (or condition-gated). Omitting it keeps the default role-gated lab. Passing it restricts `createLab` to the lab owner. + +#### New output field + +`Lab.accessPolicy` and `LabRef.accessPolicy` expose the stored policy as `LabAccessPolicy` β€” public, since policies gate access rather than being secrets. Labs without a stored policy return the synthesized `GATED` default. Render from `capabilities`; `preset` is a provenance hint and is `null` for custom configurations. + +#### New enums and inputs + +`LabAccessPreset` (`GATED` | `OPEN`), `LabCapability` (`ADD_FILES` | `MODIFY_FILES` | `DELETE_FILES` | `CREATE_ANNOUNCEMENTS` | `DECRYPT_FILES`), `LabPolicyRuleKind` (`ROLES` | `ANYONE` | `CONDITIONS`), `LabAccessPolicyInput`, `LabCapabilityPolicyInput`, and the output types `LabAccessPolicy` / `LabCapabilityPolicy`. + +#### What to watch for when integrating + +* Writes to an open lab still require authentication β€” an API Key plus a Privy session or a Service Token. Permissionless is not unauthenticated. +* On a policy-granted write, `changeBy` is pinned to the authenticated caller; a spoofed value is ignored. +* `DECRYPT_FILES` defaults to viewer-or-above (not contributor-or-above like the write capabilities) and is **not** included in the `OPEN` preset β€” an open lab's encrypted files stay member-readable-only until the owner adds an explicit rule. +* Denials add second-level causes on `details.reason`: `CAPABILITY_DENIED`, `INVALID_ACCESS_POLICY`, `POLICY_CHECK_UNAVAILABLE` (retryable), `LAB_ACCESS_CHECK_FAILED`. No new top-level `error.code` values were introduced. + +Full reference: [Access Policies](labs-api/access-policies.md). + +--- + +### Breaking: `generateDataEncryptionKey` now requires `oclId` and `accessControlConditions` + +Minting a data encryption key is now lab-scoped and bound to the file's condition array up front, closing a gap where an encrypted file's DEK could be re-published under different access conditions after the fact. + +* `generateDataEncryptionKey` gained two **required** arguments: `oclId: String!` and `accessControlConditions: String!`. Calls with no arguments now fail `VALIDATION_FAILED`. +* The returned `encryptedDek` carries a `v1:` bound-marker prefix ahead of the base64 ciphertext β€” pass it through to `finishCreateOrUpdateFile` **verbatim**. A new `dekContextVersion` field (`"v1"`, or `null` for legacy pre-cutover DEKs) surfaces the binding state on both the mutation result and `EncryptionMetadata`. +* The DEK is cryptographically bound (KMS `EncryptionContext`) to `{oclId, sha256(canonicalized accessControlConditions)}`. Passing a different condition array to `finishCreateOrUpdateFile` than the one used to generate the key produces a permanently undecryptable file. +* Minting is gated by the lab's `ADD_FILES` capability (falling back to `MODIFY_FILES`) β€” the same rule an open lab's contributions follow. + +Full reference: [Data Encryption Keys](labs-api/files.md#data-encryption-keys) and [DEK Binding](../technical-deep-dive/data/data-privacy-and-access.md#dek-binding). + +--- + +### New capability: `DECRYPT_FILES`, and a lab-level gate on `decryptDataKey` + +`decryptDataKey` called with an `oclId` is now gated by the new `DECRYPT_FILES` access-policy capability *before* the file's own `accessControlConditions` are evaluated. The default behavior is unchanged (viewer-or-above membership, for both a Privy session and a service token) β€” what's new is that a lab owner can widen it via `updateLabAccessPolicy`, so a non-member who contributed to an open lab can read their own encrypted submission back. The `tokenUri`-only path (IPFS agreement documents, no `oclId`) is unaffected and keeps its existing paid-access carve-out, but now also goes through the same x402 `allowedMutations` scope check as every other gated mutation (previously bypassed on that branch). + +Full reference: [Decrypt Authorization](../technical-deep-dive/data/data-privacy-and-access.md#decrypt-authorization). + +--- + +### Breaking: service-token wallet sign-in moves to EIP-712 + single-use nonces + +`getServiceSignInMessage` now returns a stateful, single-use challenge instead of a deterministic string, closing a replay gap where one captured signature could mint tokens indefinitely. + +* `message` is now JSON-serialized **EIP-712 typed data** β€” sign it with `eth_signTypedData_v4` (viem `signTypedData`). Signatures over the old plain-text message (`personal_sign`) are **no longer accepted**. +* The query response gained `nonce`, `issuedAt`, and `expiresAt`. The nonce is single-use, expires after roughly 10 minutes, and must be passed to `generateServiceToken`'s new required-with-signature `nonce` argument. A reused or expired nonce fails `UNAUTHENTICATED` / `details.reason: "INVALID_NONCE"`. +* `expiresIn` on both `generateServiceToken` and `extendServiceToken` is now clamped to **\[1 hour, 2 years]**; out-of-range or malformed values fail `VALIDATION_FAILED` instead of a masked internal error. + +Full reference: [Obtaining Tokens](labs-api/service-tokens.md#obtaining-tokens). + +--- + ### GraphQL introspection disabled and query depth capped in production The production endpoint (shared by all Molecule APIs β€” see [API Overview](README.md)) no longer serves `__schema` / `__type` introspection queries: they now return a validation error. `__typename` still resolves. Selection-set depth is also capped at 10 in production, with scalar leaves counted as a level (`{ root { child { name } } }` is depth 3). A query beyond that limit fails at execution time with `errorType: "QueryDepthLimitReached"` and partial data β€” a plain GraphQL error, not the catalogued error shape used elsewhere, so handle both. **Migration:** If your codegen or tooling discovers the schema by introspecting the production endpoint, that now fails β€” request a current copy of the schema from the Molecule team (see [Getting Support](README.md)) rather than introspecting production. If you see `QueryDepthLimitReached`, flatten the query to 10 levels of nesting or fewer; this limit was not previously enforced. +--- + ### `*V2` operations and pre-OCL naming removed The legacy `*V2` operations and the pre-OCL naming have been **removed**. The current API is `oclId`-based. If you are migrating from an older integration, use the current names below. diff --git a/api-reference/labs-api/README.md b/api-reference/labs-api/README.md index 263e535..2cfab25 100644 --- a/api-reference/labs-api/README.md +++ b/api-reference/labs-api/README.md @@ -20,7 +20,9 @@ The Labs API allows developers to interact with Molecule Labs datarooms without The Labs API uses consumer-credential authentication for reads and an additional Service Token for writes. Full details β€” public queries vs. protected mutations, obtaining and using credentials β€” are on the [Authentication](../authentication.md) page. -See also the functional sections: [Lab Management](lab-management.md), [Files](files.md), [Browse & Search](browse-and-search.md), [Legal Agreements](legal-agreements.md), and [Service Tokens](service-tokens.md). +See also the functional sections: [Lab Management](lab-management.md), [Access Policies](access-policies.md), [Files](files.md), [Browse & Search](browse-and-search.md), [Legal Agreements](legal-agreements.md), and [Service Tokens](service-tokens.md). + +> **Permissionless Labs.** By default a Lab is role-gated: only its owner and contributors can write. A Lab owner can additionally open specific capabilities β€” file contributions, edits, deletions, announcements β€” to any authenticated caller, to a deadline, or to wallets satisfying an onchain condition. Note that permissionless does not mean unauthenticated; see [Access Policies](access-policies.md). --- diff --git a/api-reference/labs-api/access-policies.md b/api-reference/labs-api/access-policies.md new file mode 100644 index 0000000..2ceaf9b --- /dev/null +++ b/api-reference/labs-api/access-policies.md @@ -0,0 +1,373 @@ +# Access Policies + +Every Lab is role-gated by default: only its owner and contributors may write to the data room. An **access policy** optionally opens individual capabilities to a wider set of callers β€” permissionless contributions, a time-boxed submission window, or contributions gated on an onchain condition such as holding a token. + +Policies are set when the Lab is created and changed with `updateLabAccessPolicy`. The onchain role model itself is untouched β€” see [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) for the role hierarchy this layers on top of, and [Lab Management](lab-management.md) for creating the Lab in the first place. + +*** + +## How a Policy Composes with Roles + +A policy is a per-capability rule map that can only ever **add** access on top of membership: + +``` +allowed(caller, capability) = + membership grants it (role model β€” checked first) + OR the Lab's policy rule grants it (strictly additive fallback) +``` + +Three properties follow from that ordering: + +* **A role grant always wins.** Owners and members never lose access, whatever the policy says. Locking the owner out of their own Lab is structurally impossible. +* **No policy means the classic behaviour.** A Lab created without an `accessPolicy` behaves exactly like a role-gated Lab, down to the error responses. Labs created before access policies existed are unaffected. +* **Permissionless is not unauthenticated.** Callers still authenticate β€” a Privy session or a Service Token, and any wallet can self-issue one through the [wallet-signature flow](service-tokens.md#obtaining-tokens). A policy drops the _membership_ requirement, never the _identity_ requirement. + +*** + +## Capabilities + +Five capabilities can be opened up. Each maps to the operations it guards: + +| Capability | Guards | Role default | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `ADD_FILES` | `finishCreateOrUpdateFile` with a `path` (new file), `initiateCreateOrUpdateFile`, and `generateDataEncryptionKey` β€” each only issues a slot/key, so either capability qualifies there; the authoritative decision happens at `finish` | Owner, Contributor | +| `MODIFY_FILES` | `finishCreateOrUpdateFile` with a `ref` (new version) or overwriting an existing `path`, `updateFileMetadata`, `moveEntry` | Owner, Contributor | +| `DELETE_FILES` | `deleteDataRoomFile` | Owner, Contributor | +| `CREATE_ANNOUNCEMENTS` | `createAnnouncement` | Owner, Contributor | +| `DECRYPT_FILES` | `decryptDataKey` called with an `oclId` β€” releasing the DEK for an encrypted data-room file. Not part of the `OPEN` preset: an open Lab's encrypted files stay member-readable-only unless the owner adds an explicit rule (see the [recipe](#open-lab-with-readable-encrypted-files) below) | Owner, Contributor, **Viewer** | + +`generateDataEncryptionKey` mints the key for an encrypted upload, so it is gated the same way as the write it feeds: `ADD_FILES`, falling back to `MODIFY_FILES`. On a `GATED` Lab this is contributor-or-above; on an `OPEN` Lab (`ADD_FILES: ANYONE`) any authenticated caller may mint one. See [Files](files.md#advanced-encrypted-file-upload) for the full encrypt/upload flow. + +Each capability is set to one of three rule kinds: + +| Kind | Grants to | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `ROLES` | Membership only β€” identical to leaving the capability unconfigured. Stored policies never contain `ROLES` rules; passing it removes the rule | +| `ANYONE` | Any authenticated caller | +| `CONDITIONS` | Any authenticated caller whose wallet satisfies the rule's `conditions` array | + +`ANYONE` and `CONDITIONS` rules accept two optional qualifiers, both of which close the rule again: + +| Qualifier | Effect | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `openUntil` | Unix **seconds**, compared against wall clock on every request. At and after that instant the rule falls back to `ROLES`. Must be in the future and within ten years. No background job flips anything | +| `closedWhen` | A caller-independent condition array. While it evaluates false the rule grants; once it evaluates **true** the rule falls back to `ROLES` β€” "permissionless _until_ condition X is met". An evaluation failure counts as closed (fails closed) | + +### Owner-Only Operations + +These operations are hardcoded to the Lab owner and can never appear in a policy β€” openness cannot be escalated into administration: + +| Operation | Notes | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `updateLabAccessPolicy` | Changing or removing the policy itself | +| `createLab` with `accessPolicy` | Creating a Lab open requires owner proof; a bare `createLab` keeps its usual authorization | +| `updateLabNftMetadata` | LabNFT display metadata (name, description, image, external URL) | +| `generateLabImageUploadUrl` | LabNFT image uploads | +| `signLegalAgreement` | Recording acceptance of a legal agreement | + +### Gates That Still Apply + +* **Legal agreement.** Every data-room write β€” including a contribution to a permissionless Lab β€” stays blocked until the Lab **owner** has signed the current Assignment Agreement. Opening a Lab does not bypass compliance. See [Legal Agreements](legal-agreements.md). +* **x402 tokens** keep their per-mutation scope, and that scope is checked _before_ membership and the policy. See [x402 Gateway](../x402-gateway.md). +* **Metadata reads are unchanged.** `labs`, `labWithDataRoomAndFiles`, the activity feeds, `searchLabs`, and `listLabMembers` were public before and stay public. Unencrypted file bytes remain public too. The one read-side decision a policy owns is *who can decrypt an encrypted file* β€” gated by `DECRYPT_FILES` β€” everything else is unaffected. See [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md). + +*** + +## Set a Policy When Creating a Lab + +`CreateLabInput` takes an optional `accessPolicy`. Omit it for the default role-gated Lab. + +> **Authorization**: Passing `accessPolicy` restricts `createLab` to the OCL admin (LabNFT owner + multisig signers). On the Service Token path the token's wallet must be the Lab owner. + +```graphql +mutation CreateOpenLab($oclId: String!, $accessPolicy: LabAccessPolicyInput) { + createLab(input: { oclId: $oclId, accessPolicy: $accessPolicy }) { + message + lab { + oclId + shortname + } + error { + message + code + retryable + } + } +} +``` + +**Variables:** + +```json +{ + "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042", + "accessPolicy": { "preset": "OPEN" } +} +``` + +The policy is written before the data room is registered. If the subsequent Kamu registration then fails, the policy row is rolled back β€” except on a `PROJECT_CONFLICT` (the data room already exists, so the write was an idempotent no-op and the existing row is kept). Either way, a retried `createLab` cannot strand a Lab with a policy that never actually took effect. A default (`GATED`) input stores no policy at all β€” the absence of a policy _is_ the gated state. + +*** + +## Update a Policy + +`updateLabAccessPolicy` sets the policy on an existing Lab: open it up, gate it back down with `preset: GATED`, or reconfigure individual capabilities. The input **replaces** the stored policy β€” it is not a patch. + +> **Authorization**: Restricted to the OCL admin (LabNFT owner + multisig signers) on both auth paths. + +```graphql +mutation UpdateLabAccessPolicy($oclId: String!, $input: LabAccessPolicyInput!) { + updateLabAccessPolicy(oclId: $oclId, input: $input) { + message + accessPolicy { + preset + capabilities { + capability + kind + openUntil + } + updatedAt + } + error { + message + code + retryable + } + } +} +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +| --------- | -------------------- | -------- | ---------------------------------------------- | +| oclId | String | Yes | Canonical 32-byte oclId of the lab | +| input | LabAccessPolicyInput | Yes | The complete replacement policy (not a patch) | + +Enforcement reads the stored policy through a short-lived cache of roughly 15 seconds, so a change takes effect within that window. Reading the policy back through `Lab.accessPolicy` always returns fresh state. + +*** + +## Input Reference + +`LabAccessPolicyInput` β€” accepted by both `createLab` and `updateLabAccessPolicy`: + +| Field | Type | Required | Description | +| ------------ | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ | +| preset | LabAccessPreset | No | `GATED` or `OPEN` β€” covers the common cases | +| openUntil | AWSTimestamp | No | Shorthand: applied to every non-`ROLES` rule in the expanded document β€” the preset's rules and any `capabilities` override that doesn't set its own. Requires `preset: OPEN` | +| closedWhen | String | No | Shorthand: applied to every non-`ROLES` rule in the expanded document, same reach as `openUntil` above. JSON-stringified array. Requires `preset: OPEN` | +| capabilities | \[LabCapabilityPolicyInput!] | No | Advanced per-capability rules, merged over the preset expansion | + +`LabCapabilityPolicyInput`: + +| Field | Type | Required | Description | +| ---------- | ----------------- | -------- | ----------------------------------------------------------------------------------------------- | +| capability | LabCapability | Yes | `ADD_FILES`, `MODIFY_FILES`, `DELETE_FILES`, `CREATE_ANNOUNCEMENTS`, or `DECRYPT_FILES` | +| kind | LabPolicyRuleKind | Yes | `ROLES`, `ANYONE`, or `CONDITIONS` | +| openUntil | AWSTimestamp | No | Unix seconds; `ANYONE` / `CONDITIONS` only | +| conditions | String | No\* | JSON-stringified access-control-conditions array. Required for `CONDITIONS`, rejected otherwise | +| closedWhen | String | No | JSON-stringified caller-independent condition array; `ANYONE` / `CONDITIONS` only | + +_\*`conditions` is required for `kind: CONDITIONS` and rejected for the other kinds._ + +The server expands the input deterministically β€” **preset β†’ top-level shorthand β†’ per-capability rules**, last write wins per capability, and a `ROLES` rule removes the capability's entry. The presets expand to: + +| Preset | Expands to | +| ------- | ---------------------------------------------------------------------------------------------- | +| `GATED` | `{}` β€” everything membership-gated. Use it to close a Lab back down | +| `OPEN` | `ADD_FILES: ANYONE` β€” contributions only; modify, delete, announce, and decrypt stay membership-gated | + +*** + +## Recipes + +### Permissionless contributions + +Any authenticated caller may add new files; edits, deletions, and announcements stay with members. + +```graphql +accessPolicy: { preset: OPEN } +``` + +### Open until a deadline + +Reverts to role-gated at the timestamp. There is no job to wait for β€” the instant is compared on each request. + +```graphql +accessPolicy: { preset: OPEN, openUntil: 1790812800 } +``` + +### Open until an onchain condition is met + +Contributions stay open while the condition evaluates false β€” for example, until a bounty contract reports itself closed. `closedWhen` must not reference `:userAddress`, because it is evaluated without a caller. + +```graphql +accessPolicy: { + preset: OPEN, + closedWhen: "[{\"conditionType\":\"evmContract\",\"contractAddress\":\"0xBounty…\",\"chain\":\"base\",\"functionName\":\"isClosed\",\"functionParams\":[],\"functionAbi\":{\"name\":\"isClosed\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},\"returnValueTest\":{\"key\":\"\",\"comparator\":\"=\",\"value\":\"true\"}}]" +} +``` + +### Token-gated contributions + +Only wallets holding a balance of a given ERC-20 or ERC-721 may contribute. + +```graphql +accessPolicy: { + capabilities: [{ + capability: ADD_FILES, + kind: CONDITIONS, + conditions: "[{\"conditionType\":\"evmBasic\",\"contractAddress\":\"0xToken…\",\"chain\":\"base\",\"method\":\"balanceOf\",\"parameters\":[\":userAddress\"],\"returnValueTest\":{\"key\":\"\",\"comparator\":\">\",\"value\":\"0\"}}]" + }] +} +``` + +### Open collaboration including edits + +Per-capability rules layered on top of the preset. + +```graphql +accessPolicy: { + preset: OPEN, + capabilities: [ + { capability: MODIFY_FILES, kind: ANYONE }, + { capability: DELETE_FILES, kind: ANYONE } + ] +} +``` + +### Open Lab with Readable Encrypted Files + +An `OPEN` Lab's `ADD_FILES: ANYONE` rule lets any authenticated caller upload β€” including encrypted submissions β€” but `DECRYPT_FILES` is deliberately excluded from the preset, so those encrypted files stay member-readable-only. Add an explicit rule so a bounty solver can write an encrypted submission and read it back: + +```graphql +accessPolicy: { + preset: OPEN, + capabilities: [ + { capability: DECRYPT_FILES, kind: ANYONE } + ] +} +``` + +Token-gate the read side instead with `kind: CONDITIONS` β€” for example, only holders of a bounty NFT may decrypt. Note the layering: `decryptDataKey` still evaluates the *file's own* `accessControlConditions` after this capability gate passes β€” the policy opens the door to the Lab's decrypt surface, and the file's own condition array decides per file. See [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md#decrypt-authorization) for how the two checks compose. + +### Gate a Lab back down + +```graphql +mutation GateLab($oclId: String!) { + updateLabAccessPolicy(oclId: $oclId, input: { preset: GATED }) { + message + accessPolicy { + preset + capabilities { + capability + kind + } + } + error { + message + code + retryable + } + } +} +``` + +*** + +## Reading a Policy + +`accessPolicy` is a public field on `Lab` and `LabRef` β€” policies gate access, they are not secrets. Labs without a stored policy return the synthesized `GATED` default. + +```graphql +query GetLabAccessPolicy($oclId: String!) { + labWithDataRoomAndFiles(oclId: $oclId) { + oclId + accessPolicy { + preset + capabilities { + capability + kind + openUntil + conditions + closedWhen + } + updatedAt + } + } +} +``` + +**Fields:** + +| Field | Type | Description | +| ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------- | +| preset | LabAccessPreset | The preset the policy was configured with, or `null` for a custom configuration. A provenance hint | +| capabilities | \[LabCapabilityPolicy!]! | Non-default rules only β€” a capability absent from this list follows the role model | +| updatedAt | AWSDateTime | When the policy was last written | + +`conditions` and `closedWhen` come back as `AWSJSON` β€” the stored condition arrays. + +> Render from `capabilities`, not from `preset`. `capabilities` is authoritative, and `preset` is `null` for any Lab configured per capability. + +*** + +## Access-Control Conditions + +`conditions` and `closedWhen` use the same JSON-string convention and schema as file encryption's [`accessControlConditions`](../../technical-deep-dive/data/data-privacy-and-access.md#condition-shape): an array alternating condition objects and boolean operators β€” `[condition, { "operator": "and" }, condition, …]`. Inside `conditions`, the placeholder `:userAddress` is substituted with the authenticated caller's wallet at request time. + +Write-time validation is strict. A rejected policy fails with `VALIDATION_FAILED` and `details.reason: "INVALID_ACCESS_POLICY"`, with `details.field` naming the offender: + +| Rule | Why | +| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Non-empty array, alternating condition/operator, ending on a condition | an empty array would deny everyone | +| Operators homogeneous β€” all `and` or all `or`, no nesting | mixed chains are not supported by the evaluator | +| `conditionType` must be `evmContract` or `evmBasic` | β€” | +| `evmBasic.method` must be `balanceOf`, `ownerOf`, `allowance`, or `balanceOfBatch` | evaluator ABI allowlist | +| `returnValueTest.comparator` must be `=`, `!=`, `>`, `>=`, `<`, or `<=`; the numeric comparators need an integer `value` | `contains` is not implemented | +| `chain` must be `base` or `baseSepolia` | policy conditions are pinned to the canonical chain and its testnet | +| `contractAddress` must be a valid address; `evmContract.functionAbi.name` must equal `functionName`; the ABI's `stateMutability` must be `view` or `pure` | β€” | +| `closedWhen` must not contain `:userAddress` | it is evaluated without a caller | +| `CONDITIONS` requires `conditions`; `ANYONE` rejects it; `ROLES` rejects every qualifier | β€” | +| At most 10 conditions per rule, 16 KB per array, 32 KB per policy document | β€” | +| One entry per capability β€” no duplicates | β€” | + +*** + +## Enforcement + +1. **Order of checks** β€” authentication (Privy session or Service Token, x402 scope included) β†’ membership β†’ policy. The policy is consulted only when membership denies a caller on an _existing_ Lab; a malformed `oclId` or a Lab that does not exist is never rescued by a policy. +2. **Service tokens act as wallets on open Labs** β€” a valid token's wallet is treated like a user wallet, which is how autonomous agents contribute without being granted a role. On gated Labs the Service Token path keeps its owner-only semantics for the four write capabilities. `DECRYPT_FILES` is the exception: its role default is viewer-or-above for **both** caller kinds, matching the membership check `decryptDataKey` already applied before access policies existed. +3. **A rule grants** when `openUntil` has not passed, `closedWhen` does not evaluate true, and the caller satisfies `conditions` (`ANYONE` needs no wallet check). Conditions are evaluated against live chain state on each request. +4. **Add-only means add-only** β€” a caller granted `ADD_FILES` but not `MODIFY_FILES` cannot write over an existing path. "Create or update" never silently becomes an overwrite. +5. **Attribution is pinned** β€” on a policy-granted write, `changeBy` is forced to the authenticated caller and a spoofed `changeBy` argument is ignored. Members keep their self-declared `changeBy`. +6. **Fail closed** β€” if a condition cannot be evaluated (RPC failure, evaluator unavailable), the request is denied with a retryable error rather than allowed. + +*** + +## Error Responses + +Access-policy failures reuse the existing error codes; the specific cause rides on `details.reason`: + +| `error.code` | `details.reason` | When | +| ----------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `UNAUTHORIZED` | `UNAUTHORIZED` | The caller is not a member and the policy does not open this capability β€” a gated Lab, a lapsed `openUntil`, or a `closedWhen` that has flipped | +| `UNAUTHORIZED` | `CAPABILITY_DENIED` | The caller does not satisfy the rule's conditions, or an add-only caller targeted an existing path. `details.capability` names the capability | +| `VALIDATION_FAILED` | `INVALID_ACCESS_POLICY` | The policy input was rejected at write time; `details.field` pinpoints it | +| `UPSTREAM_UNAVAILABLE` (retryable) | `POLICY_CHECK_UNAVAILABLE` | A condition could not be evaluated. Fails closed β€” retry with backoff | +| `INTERNAL_ERROR` | `LAB_ACCESS_CHECK_FAILED` | Unexpected failure inside the access check. Fails closed | + +The first row is deliberately indistinguishable from the classic denial: a Lab whose window has lapsed returns exactly what a Lab that was never opened returns. + +*** + +## See Also + +* [Lab Management](lab-management.md) β€” creating a Lab, LabNFT metadata, members, DID linking +* [Files](files.md) β€” the upload flow and the mutations these capabilities guard +* [Roles & Permissions](../../technical-deep-dive/roles-and-permissions.md) β€” the onchain role model a policy layers on top of +* [Data Privacy & Access](../../technical-deep-dive/data/data-privacy-and-access.md) β€” the condition shape, `DECRYPT_FILES`'s two-layer check, and DEK binding +* [Legal Agreements](legal-agreements.md) β€” the owner-signature gate that applies to every write + +*** diff --git a/api-reference/labs-api/files.md b/api-reference/labs-api/files.md index 3e9d1a8..39af03c 100644 --- a/api-reference/labs-api/files.md +++ b/api-reference/labs-api/files.md @@ -2,6 +2,8 @@ Working with files in a Lab dataroom: the three-step upload flow (initiate β†’ upload β†’ finish), plus announcements, metadata updates, deletion, storage limits, and client-side encryption. Creating the Lab itself is covered in [Lab Management](lab-management.md). +> **Who may write.** By default every mutation on this page requires the Lab owner or a contributor. A Lab owner can widen that per action β€” uploads, edits, deletions, and announcements are individually openable to any authenticated caller, to a deadline, or to wallets satisfying an onchain condition. See [Access Policies](access-policies.md). + ## Step 1: Initiate File Upload Initiates the upload process and returns a presigned URL for direct file upload. @@ -169,7 +171,7 @@ mutation FinishFileUpload( | uploadToken | String | Yes | Token received from Step 1 | | path | String | No\* | File name for NEW files (e.g., `research-data.pdf`) | | ref | String | No\* | Dataset ID for NEW VERSIONS of existing files | -| changeBy | String | Yes | Wallet address of user making the change | +| changeBy | String | Yes | Wallet address of user making the change (see the note below on labs open to non-members) | | description | String | No | Optional file description | | tags | \[String] | No | Optional tags for categorization | | categories | \[String] | No | Optional categories for organization | @@ -177,6 +179,8 @@ mutation FinishFileUpload( _\*Use `path` for new files OR `ref` for versions - not both_ +> **On labs open to non-members** ([Access Policies](access-policies.md)): a caller granted contribution access by policy rather than by role may only create **new** files. Writing a new version (`ref`) or overwriting an existing `path` additionally requires the modify capability, and is otherwise rejected with `UNAUTHORIZED` / `details.reason: "CAPABILITY_DENIED"`. For such writes `changeBy` is also pinned to the authenticated caller β€” a different value in the argument is ignored, so contributions stay attributable. Members keep their self-declared `changeBy`. + **Example Request (curl):** ```bash @@ -673,7 +677,11 @@ For files requiring client-side encryption, obtain a data encryption key via the ### Obtain a DEK, then encrypt locally -`generateDataEncryptionKey` (no arguments) returns `plaintextDEK`, `encryptedDek`, and `encryptionSystem`. The client uses `plaintextDEK` to AES-256-GCM encrypt the file locally (Web Crypto `SubtleCrypto`), then wipes it from memory. The upload itself uses the standard `initiateCreateOrUpdateFile` β†’ PUT β†’ `finishCreateOrUpdateFile` flow, with the encrypted bytes uploaded to the presigned URL. +> **Breaking change.** `generateDataEncryptionKey` now takes two required arguments, `oclId` and `accessControlConditions`, and returns a `dekContextVersion` field alongside the DEK. Callers still on the old no-argument call will get a validation error β€” see [Data Encryption Keys](#data-encryption-keys) below for the full shape and why the ACC array has to be supplied up front. + +The flow is: choose the file's `accessControlConditions` array β†’ call `generateDataEncryptionKey(oclId, accessControlConditions)` β†’ encrypt locally with the returned `plaintextDEK` β†’ `finishCreateOrUpdateFile` with the **same** `accessControlConditions` and the returned `encryptedDek` passed through **verbatim**. The client uses `plaintextDEK` to AES-256-GCM encrypt the file locally (Web Crypto `SubtleCrypto`), then wipes it from memory. The upload itself uses the standard `initiateCreateOrUpdateFile` β†’ PUT β†’ `finishCreateOrUpdateFile` flow, with the encrypted bytes uploaded to the presigned URL. + +Minting a key is gated the same way as the write it feeds β€” the lab's `ADD_FILES` capability, falling back to `MODIFY_FILES` β€” so on an [open Lab](access-policies.md) any authenticated caller can request one. See [Access Policies](access-policies.md#capabilities) for the full capability model. ### Encryption Metadata Parameter (Onchain-Verified Envelope Encryption, current default) @@ -697,6 +705,8 @@ $encryptionMetadata: EncryptionMetadataInput `encryptionSystem` is **backend-set** β€” clients must echo the value returned by `generateDataEncryptionKey` rather than hardcode it. This keeps the roadmap rollover to BLS threshold key custody transparent to existing integrations. +`encryptedDek` must also be passed through **exactly** as returned β€” it carries a `v1:` bound-marker prefix ahead of the base64 ciphertext that records the DEK's binding to this lab and condition array (see [Data Encryption Keys](#data-encryption-keys)). Stripping or altering that prefix makes the file permanently undecryptable. + #### `accessControlConditions` β€” gating decryption by role `accessControlConditions` is a JSON-stringified array of `EvmContractCondition` predicates joined by `BooleanCondition` separators (`and` / `or`). The backend evaluates each predicate against live chain state at decrypt time via viem `readContract`, short-circuits booleans, and fails closed on RPC error. To gate decryption on _LabNFT owner OR active Contributor OR active Viewer_, OR `AccessResolver.isAuthorizedSignerForTba(:userAddress, tba)` against `AccessResolver.hasRole(oclId, :userAddress, ROLE_VIEWER)` β€” the role-hierarchy collapses Contributor + Viewer into one check on the canonical chain (Base). @@ -719,15 +729,21 @@ Role grants are **onchain transactions on the `AccessResolver` contract**, not L ### Generate a Data Encryption Key -Generate a standalone data encryption key (DEK) for client-side encryption outside the file-upload flow. Returns both the plaintext DEK (used to encrypt data locally, then wiped) and the KMS-encrypted DEK (stored alongside the ciphertext). Requires authentication (Privy user or service token). See [Advanced: Encrypted File Upload](#advanced-encrypted-file-upload) for the file-upload encryption path. +Generate a data encryption key (DEK) for client-side encryption of a lab data-room file. Returns the plaintext DEK (used to encrypt data locally, then wiped), the KMS-encrypted DEK (stored alongside the ciphertext), and a `dekContextVersion` marker. Requires authentication (Privy user or service token) and the lab's `ADD_FILES` capability (`MODIFY_FILES` also accepted) β€” see [Access Policies](access-policies.md#capabilities). See [Advanced: Encrypted File Upload](#advanced-encrypted-file-upload) for the file-upload encryption path. + +> **Breaking change.** `oclId` and `accessControlConditions` are now required. The DEK is cryptographically bound (as a KMS `EncryptionContext`) to `{oclId, sha256(canonicalized accessControlConditions)}` β€” passing a *different* condition array to `finishCreateOrUpdateFile` than the one used here produces a permanently undecryptable file, so treat the pair as one atomic choice. ```graphql -mutation GenerateDataEncryptionKey { - generateDataEncryptionKey { +mutation GenerateDataEncryptionKey($oclId: String!, $accessControlConditions: String!) { + generateDataEncryptionKey( + oclId: $oclId + accessControlConditions: $accessControlConditions + ) { isSuccess plaintextDEK encryptedDek encryptionSystem + dekContextVersion error { message code @@ -737,11 +753,26 @@ mutation GenerateDataEncryptionKey { } ``` -| Field | Type | Description | -| ---------------- | ------ | ---------------------------------------------------------- | -| plaintextDEK | String | Base64-encoded plaintext DEK (only present on success) | -| encryptedDek | String | Base64-encoded KMS-encrypted DEK (only present on success) | -| encryptionSystem | String | Encryption system used (always `"kms"`) | +**Variables:** + +```json +{ + "oclId": "0x0101000000000000000000000000000000000000000000000000000000000042", + "accessControlConditions": "[{\"conditionType\":\"evmContract\",\"contractAddress\":\"0x...AccessResolver\",\"chain\":\"base\",\"functionName\":\"hasRole\",\"functionParams\":[\"\",\":userAddress\",\"1\"],\"functionAbi\":{...},\"returnValueTest\":{\"key\":\"\",\"comparator\":\"=\",\"value\":\"true\"}}]" +} +``` + +| Field | Type | Description | +| ----------------------- | ------ | ------------------------------------------------------------------------------------------------ | +| oclId | String | Yes β€” canonical 32-byte oclId of the lab the file will be stored in | +| accessControlConditions | String | Yes β€” JSON-stringified condition array the file will be gated by; same schema as `EncryptionMetadataInput.accessControlConditions`, validated strictly at mint time | + +| Field | Type | Description | +| ----------------- | ------ | ---------------------------------------------------------------------------------------------- | +| plaintextDEK | String | Base64-encoded plaintext DEK (only present on success) | +| encryptedDek | String | KMS-encrypted DEK, carrying a `v1:` bound-marker prefix β€” pass it to `finishCreateOrUpdateFile` **verbatim** | +| encryptionSystem | String | Encryption system used (always `"kms"`) | +| dekContextVersion | String | `"v1"` β€” the DEK is bound via KMS `EncryptionContext` to this lab and condition array | --- diff --git a/api-reference/labs-api/lab-management.md b/api-reference/labs-api/lab-management.md index 0e2036c..1de1a03 100644 --- a/api-reference/labs-api/lab-management.md +++ b/api-reference/labs-api/lab-management.md @@ -1,6 +1,6 @@ # Lab Management -Operations for creating and administering a Lab: creating the dataroom, managing its LabNFT display metadata, managing members, and linking its decentralised identifier (DID). +Operations for creating and administering a Lab: creating the dataroom, managing its LabNFT display metadata, managing members, and linking its decentralised identifier (DID). Who may contribute to the dataroom is covered in [Access Policies](access-policies.md). *** @@ -38,9 +38,12 @@ mutation CreateLab($oclId: String!) { The mutation takes a single `CreateLabInput` object: -| Field | Type | Required | Description | -| ----- | ------ | -------- | ------------------------------------------------------------- | -| oclId | String | Yes | Canonical 32-byte oclId (lowercase 0x-hex) of the onchain lab | +| Field | Type | Required | Description | +| ------------ | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------- | +| oclId | String | Yes | Canonical 32-byte oclId (lowercase 0x-hex) of the onchain lab | +| accessPolicy | LabAccessPolicyInput | No | Contribution-access policy. Omit for the default role-gated lab β€” see [Access Policies](access-policies.md) | + +> **Creating a lab open to contributions**: pass `accessPolicy: { preset: OPEN }` to let any authenticated caller add files, or configure per-capability rules and onchain conditions. The policy can be changed later with `updateLabAccessPolicy`. Both are owner-only β€” full details in [Access Policies](access-policies.md). **Prerequisites:** diff --git a/api-reference/labs-api/service-tokens.md b/api-reference/labs-api/service-tokens.md index e9e01b2..c8407ef 100644 --- a/api-reference/labs-api/service-tokens.md +++ b/api-reference/labs-api/service-tokens.md @@ -4,7 +4,9 @@ Service tokens must be requested from the Molecule team (see [Authentication](../authentication.md) section above). -Alternatively, a service can obtain a token **self-service** by proving control of its wallet β€” useful for autonomous agents, bots, and CI/CD pipelines that don't have a browser-based Privy session. This is a two-step flow: fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token. +Alternatively, a service can obtain a token **self-service** by proving control of its wallet β€” useful for autonomous agents, bots, and CI/CD pipelines that don't have a browser-based Privy session. This is a three-step flow: fetch a sign-in message carrying a single-use nonce, sign it with the service wallet, then exchange the signature (and nonce) for a token. + +> **Breaking change.** The sign-in message is now **EIP-712 typed data**, not a plain string, and must be signed with `eth_signTypedData_v4` β€” signatures over the old `personal_sign` message are no longer accepted. Each message also embeds a server-issued, single-use `nonce` that expires after about 10 minutes; a captured signature can mint exactly one token, and only within that window. **Step 1 β€” Get the sign-in message (`getServiceSignInMessage`):** @@ -15,6 +17,9 @@ query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { serviceName: $serviceName ) { message + nonce + issuedAt + expiresAt } } ``` @@ -26,21 +31,43 @@ query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) { Public query β€” no authentication required. -**Step 2 β€” Exchange the signature for a token (`generateServiceToken`):** +| Field | Type | Description | +| ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | +| message | String | JSON-serialized EIP-712 typed data (`{domain, types, primaryType, message}`) β€” sign this whole string, don't hand-edit it | +| nonce | String | Single-use nonce embedded in `message`; echo it into `generateServiceToken` | +| issuedAt | AWSTimestamp | Unix seconds the message was issued at | +| expiresAt | AWSTimestamp | Unix seconds the nonce (and any signature over it) expires at β€” sign and redeem before this | + +**Step 2 β€” Sign the typed data:** + +Sign `message` with `eth_signTypedData_v4`, then submit the signature and nonce: + +```ts +import { createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +const account = privateKeyToAccount(SERVICE_WALLET_PRIVATE_KEY); +const client = createWalletClient({ account, transport: http(RPC_URL) }); + +const typedData = JSON.parse(message); // `message` from Step 1 +const messageSignature = await client.signTypedData({ account, ...typedData }); +``` -Sign the returned `message` with the service wallet, then submit the signature: +**Step 3 β€” Exchange the signature for a token (`generateServiceToken`):** ```graphql mutation GenerateServiceToken( $serviceName: String! $walletAddress: String! $messageSignature: String! + $nonce: String! $expiresIn: String ) { generateServiceToken( serviceName: $serviceName walletAddress: $walletAddress messageSignature: $messageSignature + nonce: $nonce expiresIn: $expiresIn ) { token @@ -54,14 +81,15 @@ mutation GenerateServiceToken( } ``` -| Parameter | Type | Required | Description | -| ---------------- | ------ | -------- | ---------------------------------------------------------------------------- | -| serviceName | String | Yes | Name of the service the token is issued for | -| walletAddress | String | No\* | Service wallet address (required together with `messageSignature`) | -| messageSignature | String | No\* | Hex-encoded signature of the sign-in message (required with `walletAddress`) | -| expiresIn | String | No | Token lifetime (e.g. `"30d"`, `"720h"`) | +| Parameter | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| serviceName | String | Yes | Name of the service the token is issued for | +| walletAddress | String | No\* | Service wallet address (required together with `messageSignature` and `nonce`) | +| messageSignature | String | No\* | Hex-encoded `eth_signTypedData_v4` signature over the typed data from Step 1 | +| nonce | String | No\* | The nonce from Step 1; consumed atomically on a successful mint β€” reusing it fails with `UNAUTHENTICATED` / `details.reason: "INVALID_NONCE"` | +| expiresIn | String | No | Token lifetime (e.g. `"30d"`, `"720h"`), clamped to **\[1 hour, 2 years]** β€” out of range or malformed fails `VALIDATION_FAILED`. Defaults to 180 days | -\* `walletAddress` and `messageSignature` must be provided together for signature-based issuance. The returned `token` is the JWT to pass as `X-Service-Token` on subsequent requests. +\* `walletAddress`, `messageSignature`, and `nonce` must all be provided together for signature-based issuance. The returned `token` is the JWT to pass as `X-Service-Token` on subsequent requests. The same `expiresIn` clamp applies to `extendServiceToken` below. ## Extending Token Expiration @@ -84,7 +112,7 @@ mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { | Parameter | Type | Description | | --------- | ------ | ----------------------------------------------- | | tokenId | String | Token ID provided when token was generated | -| expiresIn | String | New duration (e.g., `"30d"`, `"720h"`, `"90d"`) | +| expiresIn | String | New duration (e.g., `"30d"`, `"720h"`, `"90d"`), clamped to **\[1 hour, 2 years]** | **Example:** diff --git a/technical-deep-dive/data/data-privacy-and-access.md b/technical-deep-dive/data/data-privacy-and-access.md index 4f34494..89c25ee 100644 --- a/technical-deep-dive/data/data-privacy-and-access.md +++ b/technical-deep-dive/data/data-privacy-and-access.md @@ -27,24 +27,28 @@ Files marked as Public skip encryption entirely. The researcher explicitly choos ### Upload Flow ``` -1. Client β†’ AppSync: generateDataEncryptionKey() -2. Backend: authenticate caller (Privy JWT or service token + role check) -3. Backend: issue a fresh per-file DEK β†’ - returns { plaintextDEK (one-shot), encryptedDek, encryptionSystem } -4. Backend: zero its copy of the plaintextDEK after the response is built -5. Client: AES-256-GCM encrypt(file, plaintextDEK) via SubtleCrypto -6. Client β†’ AppSync: initiateCreateOrUpdateFile(oclId, contentType, contentLength) +1. Client: choose accessControlConditions (EvmContractCondition array) for the file +2. Client β†’ AppSync: generateDataEncryptionKey(oclId, accessControlConditions) +3. Backend: authenticate caller + check the lab's ADD_FILES capability + (membership or access policy β€” MODIFY_FILES also accepted) +4. Backend: issue a fresh per-file DEK, bound via KMS EncryptionContext to + {oclId, sha256(canonicalized accessControlConditions)} β†’ + returns { plaintextDEK (one-shot), encryptedDek ("v1:"-prefixed), + encryptionSystem, dekContextVersion } +5. Backend: zero its copy of the plaintextDEK after the response is built +6. Client: AES-256-GCM encrypt(file, plaintextDEK) via SubtleCrypto +7. Client β†’ AppSync: initiateCreateOrUpdateFile(oclId, contentType, contentLength) β†’ returns presigned upload URL + uploadToken -7. Client: PUT ciphertext to presigned S3 URL -8. Client: build accessControlConditions (EvmContractCondition array) +8. Client: PUT ciphertext to presigned S3 URL 9. Client β†’ AppSync: finishCreateOrUpdateFile(oclId, uploadToken, encryptionMetadata: { encryptionSystem, encryptedDek, - iv, contentHash, accessControlConditions, + iv, contentHash, + accessControlConditions, # SAME array as step 1 encryptedBy, encryptedAt }) 10. Client: wipe plaintextDEK from memory ``` -The client opts in to encryption by requesting a DEK via `generateDataEncryptionKey`; unencrypted uploads simply skip that step. The backend decides which encryption system to use and returns it in `encryptionSystem` β€” clients must echo this value verbatim, never hardcode it. This keeps the roadmap upgrade to BLS threshold key custody transparent to existing integrations. +The client opts in to encryption by requesting a DEK via `generateDataEncryptionKey`; unencrypted uploads simply skip that step. `accessControlConditions` must be decided *before* calling it β€” the DEK is cryptographically bound to that exact array (see [DEK Binding](#dek-binding) below), so step 9 must pass the same array back, and `encryptedDek` must be forwarded verbatim. The backend decides which encryption system to use and returns it in `encryptionSystem` β€” clients must echo this value verbatim, never hardcode it. This keeps the roadmap upgrade to BLS threshold key custody transparent to existing integrations. ### Access Conditions @@ -184,17 +188,42 @@ Decryption is **condition-authoritative**: the backend reads the stored conditio ``` 1. Client β†’ AppSync: decryptDataKey(oclId, filePath | tokenUri + agreementUrl) 2. Backend: authenticate caller (Privy JWT or service token) -3. Backend: fetch stored encryptionMetadata +3. Backend (oclId path only): DECRYPT_FILES capability check β€” membership + (viewer-or-above) or the lab's access policy +4. Backend: fetch stored encryptionMetadata β€’ filePath β†’ Kamu (ODF): file's accessControlConditions + encryptedDek β€’ tokenUri β†’ IPFS: IPNFT JSON β†’ matching agreement's encryption block -4. Backend: evaluate accessControlConditions against live chain state (EVM RPC) -5. Backend: unwrap the DEK via the protocol key custodian β†’ plaintextDEK -6. Backend: zero plaintextDEK buffer after the response is built -7. Client: download ciphertext from S3 (data room) or IPFS (agreement) -8. Client: AES-256-GCM decrypt(ciphertext, plaintextDEK, iv) via SubtleCrypto -9. Client: wipe plaintextDEK from memory +5. Backend: evaluate accessControlConditions against live chain state (EVM RPC) +6. Backend: unwrap the DEK via the protocol key custodian β†’ plaintextDEK + (rebuilding the KMS EncryptionContext first for a context-bound DEK) +7. Backend: zero plaintextDEK buffer after the response is built +8. Client: download ciphertext from S3 (data room) or IPFS (agreement) +9. Client: AES-256-GCM decrypt(ciphertext, plaintextDEK, iv) via SubtleCrypto +10. Client: wipe plaintextDEK from memory ``` +### Decrypt Authorization + +Reading an encrypted data-room file's DEK passes through **two independent checks**, in order: + +1. **Lab-level capability gate (`DECRYPT_FILES`)** β€” runs only on the `oclId` path (not the `tokenUri` path, see below), before the stored metadata is even fetched. By default this is membership: Owner, Contributor, or **Viewer** β€” the same viewer-or-above gate `decryptDataKey` has always applied, now expressed as a capability. A Lab owner can widen it with an [access policy](../../api-reference/labs-api/access-policies.md) β€” `ANYONE` or `CONDITIONS` β€” so a non-member can read back files it was permissionlessly allowed to write. It is never part of the `OPEN` preset; opening a Lab for contributions does not, by itself, make its encrypted files readable by non-members. +2. **The file's own `accessControlConditions`** β€” evaluated exactly as described above, against live chain state, regardless of how the first check was satisfied. + +Both must pass. The capability gate answers "can this caller use this Lab's decrypt surface at all"; the file's own conditions answer "does this specific file grant this caller access". A file can be even more restrictive than the Lab-level gate (e.g. token-gated to a subset of Contributors) but never less. + +The `tokenUri`-only path (no `oclId`, used for IPFS-pinned agreement documents) skips the capability gate entirely β€” authorization there is the paid-access carve-out described in [Agentic Encryption](#agentic-encryption) and the [x402 Gateway](../../api-reference/x402-gateway.md), so only authentication is required before its own condition evaluation. + +### DEK Binding + +DEKs minted by `generateDataEncryptionKey` are cryptographically bound, as a KMS `EncryptionContext`, to `{oclId, sha256(canonicalized accessControlConditions)}`. The stored `encryptedDek` carries a `v1:` marker ahead of the base64 ciphertext recording that binding; it is surfaced read-side as `EncryptionMetadata.dekContextVersion`. Practical consequences: + +* **ACC repointing is closed.** Re-publishing an old `encryptedDek` under a different condition array changes the derived hash, so KMS refuses to unwrap it even though the ciphertext itself is intact. +* **No cross-lab reuse.** The `oclId` in the context pins a DEK to the Lab it was minted for. +* **The `tokenUri` decrypt path never builds a context**, so a bound data-room DEK copied into an IPFS agreement document is structurally undecryptable there. +* **Legacy DEKs** (minted before this cutover, no `v1:` marker) decrypt context-free as before and remain on the migration backlog. + +Client contract: pass the exact same `accessControlConditions` to `generateDataEncryptionKey` and to `finishCreateOrUpdateFile`, and store the returned `encryptedDek` verbatim β€” key order and whitespace are canonicalized away, but any value difference produces a permanently undecryptable file. See [Files](../../api-reference/labs-api/files.md#advanced-encrypted-file-upload) for the client-side flow. + The GraphQL interface: ```graphql @@ -220,8 +249,8 @@ AI agents encrypt and decrypt lab files through the same GraphQL interface, usin * **Auth** β€” The agent authenticates with an `X-Service-Token` JWT. For short-lived access, the [x402 Gateway](../../api-reference/x402-gateway.md) mints a per-request token scoped to one mutation after verifying a USDC payment. For long-lived agents, the Molecule team provisions a service token tied to a wallet and an `allowedMutations` list. * **Role grant** β€” The Lab owner grants the agent's wallet a Contributor (or Viewer) role via `AccessResolver.grantRole` with `isAgent = true` and a bounded `expiry`. The `isAgent` flag is surfaced in the team-members UI so agent session keys are clearly distinguished from human collaborators. -* **Encrypt** β€” The agent calls `generateDataEncryptionKey`, receives a plaintext DEK, encrypts the file locally (Node.js `crypto` / Web Crypto), uploads the ciphertext via `initiateCreateOrUpdateFile` β†’ PUT, then calls `finishCreateOrUpdateFile` with the encryption metadata. -* **Decrypt** β€” The agent calls `decryptDataKey(oclId, filePath)`. The backend evaluates the stored conditions against live chain state; a valid Viewer/Contributor grant satisfies the `hasRole` predicate. The backend returns the plaintext DEK over TLS; the agent decrypts locally. +* **Encrypt** β€” The agent calls `generateDataEncryptionKey(oclId, accessControlConditions)`, receives a plaintext DEK bound to that Lab and condition array, encrypts the file locally (Node.js `crypto` / Web Crypto), uploads the ciphertext via `initiateCreateOrUpdateFile` β†’ PUT, then calls `finishCreateOrUpdateFile` with the **same** `accessControlConditions` and the encryption metadata. Minting the key requires the Lab's `ADD_FILES` capability β€” role-based, or granted by the Lab's [access policy](../../api-reference/labs-api/access-policies.md). +* **Decrypt** β€” The agent calls `decryptDataKey(oclId, filePath)`. The backend first checks the `DECRYPT_FILES` capability (viewer-or-above by default, or whatever the Lab's access policy grants), then evaluates the file's stored conditions against live chain state; a valid Viewer/Contributor grant satisfies the `hasRole` predicate. The backend returns the plaintext DEK over TLS; the agent decrypts locally. See [Decrypt Authorization](#decrypt-authorization) above. * **Expiry** β€” When the role grant expires (`block.timestamp >= expiry`), `hasRole` returns `false` and `decryptDataKey` starts failing with a conditions-not-met error. The agent must request a fresh grant β€” typically from an owner-controlled orchestrator β€” before it can continue. See the [Developers / AI Agents guide](../../user-guides/developers-ai-agents.md) for end-to-end agent integration patterns and the [MCP Tools reference](../../references/mcp-tools.md) for the read-side agent toolset. @@ -230,7 +259,7 @@ See the [Developers / AI Agents guide](../../user-guides/developers-ai-agents.md The net result of this architecture is that no single party has unilateral access to confidential research data. The file content is encrypted before it leaves the client, transmitted as ciphertext, stored as ciphertext across the decentralised storage stack, and only ever decrypted inside an authorised client after access conditions have been re-verified against live onchain state. Every action against the data β€” uploads, version changes, access events β€” is recorded with the author's decentralised identifier, creating a tamper-evident provenance trail (see [Data Storage](data-storage.md) for the persistence and provenance layer). -
LayerProtectionMechanism
At restFile content encrypted before leaving clientClient-side AES-256-GCM with a per-file wrapped DEK
In transitAll communications over HTTPS; payload is ciphertextTLS + pre-encryption
Key storagePlaintext DEK is never persisted; the wrapped DEK is useless without the custodianProtocol-operated key custodian today; BLS threshold operator network on roadmap
Access controlDecryption gated by a live onchain verification of stored conditionsAccessResolver (hasRole, isAuthorizedSigner*); IPNFT.canRead for legacy files
During decryptionPlaintext DEK only exists inside the authorised client for the sessionClient-side key assembly and decryption; backend zeroes its copy
ProvenanceEvery file action tracked with author's DIDRecorded in Kamu (ODF) β€” see Data Storage
PermanenceEncrypted content persists even if the file record is removed; keys are stored separatelyCiphertext on IPFS + Arweave β€” see Data Storage
+
LayerProtectionMechanism
At restFile content encrypted before leaving clientClient-side AES-256-GCM with a per-file wrapped DEK
In transitAll communications over HTTPS; payload is ciphertextTLS + pre-encryption
Key storagePlaintext DEK is never persisted; the wrapped DEK is bound to its Lab and condition array and useless without both the custodian and a matching contextProtocol-operated key custodian today (KMS EncryptionContext binding); BLS threshold operator network on roadmap
Access controlDecryption gated by a Lab-level capability check, then a live onchain verification of the file's own stored conditionsDECRYPT_FILES (membership or access policy); AccessResolver (hasRole, isAuthorizedSigner*); IPNFT.canRead for legacy files
During decryptionPlaintext DEK only exists inside the authorised client for the sessionClient-side key assembly and decryption; backend zeroes its copy
ProvenanceEvery file action tracked with author's DIDRecorded in Kamu (ODF) β€” see Data Storage
PermanenceEncrypted content persists even if the file record is removed; keys are stored separatelyCiphertext on IPFS + Arweave β€” see Data Storage
### Roadmap diff --git a/technical-deep-dive/roles-and-permissions.md b/technical-deep-dive/roles-and-permissions.md index 2f477f1..2cc9c9a 100644 --- a/technical-deep-dive/roles-and-permissions.md +++ b/technical-deep-dive/roles-and-permissions.md @@ -41,6 +41,29 @@ A Contributor cannot "downgrade" another Contributor to Viewer β€” downgrades ar > **Protocol admin.** In addition to per-lab owners, the `AccessResolver` contract owner β€” Molecule's protocol multisig β€” is a global role admin: it can grant and revoke roles on any lab and passes every `hasRole` check. This is the operational escape hatch for support and recovery flows. +## Opening a Lab Beyond Its Members + +The role model answers "who is a member of this Lab". Some Labs want a second, wider answer for a narrow set of actions β€” an open call for datasets, a bounty hub where any solver may submit results, a token-gated community drop. For those, a Lab owner can attach an **access policy**: a per-capability rule that grants specific data-room actions to callers who hold no role at all. + +A policy is enforced offchain, by the Labs API, and is **strictly additive** β€” membership is checked first, and a role grant always wins: + +``` +allowed(caller, capability) = + membership grants it (role model β€” checked first) + OR the Lab's policy rule grants it (strictly additive fallback) +``` + +Because a policy can only ever add access, owners and members never lose access whatever a policy says, and locking the owner out is structurally impossible. A Lab without a policy behaves exactly as described in the sections above. + +Five capabilities can be opened, each independently: adding files, modifying them (new versions, metadata, moves), deleting them, creating announcements, and decrypting encrypted files. Each is set to one of three rule kinds β€” membership only (the default), any authenticated caller, or any caller whose wallet satisfies an [access-control-condition array](data/data-privacy-and-access.md#condition-shape) such as holding a token. A rule can additionally be time-boxed to a deadline, or wired to a caller-independent condition that closes it once it becomes true β€” "permissionless until this bounty contract reports itself closed". Decrypting is the odd one out: its default is Viewer-or-above rather than Contributor-or-above, and it is never opened automatically by the `OPEN` preset β€” an open Lab's encrypted files stay member-readable-only until the owner adds an explicit rule. + +Two boundaries are worth stating explicitly: + +* **Permissionless is not unauthenticated.** Contributors without a role still authenticate with a wallet-backed identity β€” a Privy session or a self-issued service token. What the policy removes is the membership requirement, not the identity requirement, so every contribution stays attributable. +* **Openness never escalates into administration.** Changing the policy, editing LabNFT metadata, uploading a Lab image, and signing the legal agreement are hardcoded to the owner and can never appear in a policy. Role grants themselves remain onchain and owner-controlled, and every data-room write still requires the owner to have signed the current Assignment Agreement. + +The API surface β€” presets, per-capability rules, condition schema, and error responses β€” is documented in [Access Policies](../api-reference/labs-api/access-policies.md). + ## Grants: Expiry & Agent Flag Each grant is an onchain record with three fields: @@ -127,6 +150,7 @@ Use these events to reconstruct the team-members list for a lab offchain; the on ## See Also +* [Access Policies](../api-reference/labs-api/access-policies.md) β€” opening a Lab's data room beyond its members: presets, per-capability rules, and onchain conditions. * [AccessResolver contract reference](../references/contracts/accessresolver.md) β€” full ABI, deployments, signer-authorization predicates (`isAuthorizedSignerForIpnft`, `isAuthorizedSignerForTba`). * [Data Privacy & Access](data/data-privacy-and-access.md) β€” how role checks feed into file encryption / decryption. * [Molecule Labs](onchain-lab.md) β€” how `oclId` is derived and why ownership resolves through the TBA. diff --git a/user-guides/developers-ai-agents.md b/user-guides/developers-ai-agents.md index b0235c2..74a9abc 100644 --- a/user-guides/developers-ai-agents.md +++ b/user-guides/developers-ai-agents.md @@ -30,7 +30,7 @@ If you're building an interface that displays Lab data, IPT markets, or research Use the Molecule API for market and token data β€” IPTs with prices, project summaries, and activity feeds. For real-time onchain state that isn't indexed β€” for example, live token balances, allowances, or contract state β€” call the contracts directly via viem. -For data room interactions (showing a Lab's files, uploading research data on behalf of a user), use the Labs API. File uploads follow a three-step flow: call `initiateCreateOrUpdateFile` to get a presigned S3 URL, PUT the file to that URL, then call `finishCreateOrUpdateFile` with metadata. If the file needs encryption, first request a key via `generateDataEncryptionKey` β€” the backend returns a one-shot plaintext DEK and its encrypted form β€” and AES-256-GCM encrypt the file locally via Web Crypto before uploading, attaching the encryption metadata on finish. On download, `decryptDataKey` returns the unwrapped DEK after the backend re-verifies the file's access conditions against live onchain state. The [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md) page walks through both flows end-to-end. +For data room interactions (showing a Lab's files, uploading research data on behalf of a user), use the Labs API. File uploads follow a three-step flow: call `initiateCreateOrUpdateFile` to get a presigned S3 URL, PUT the file to that URL, then call `finishCreateOrUpdateFile` with metadata. If the file needs encryption, first request a key via `generateDataEncryptionKey(oclId, accessControlConditions)` β€” the backend returns a one-shot plaintext DEK bound to that lab and condition array, plus its encrypted form β€” and AES-256-GCM encrypt the file locally via Web Crypto before uploading, attaching the same `accessControlConditions` and the encryption metadata on finish. On download, `decryptDataKey` returns the unwrapped DEK after checking the lab's `DECRYPT_FILES` capability and re-verifying the file's own access conditions against live onchain state. The [Data Privacy & Access](../technical-deep-dive/data/data-privacy-and-access.md) page walks through both flows end-to-end. Authentication splits into two paths. Unauthenticated calls work for all read operations against public data β€” IPT listings, market data, project summaries. Authenticated calls require a Privy JWT token and wallet address, and are needed for any write operation or access to private data rooms. @@ -52,14 +52,16 @@ The Base Sepolia deployment includes all the infrastructure you need for testing AI agents that operate on Lab data β€” reading files, running analyses, writing findings back β€” interact through the Labs API. The protocol treats agent outputs the same as any other data: versioned records with content identifiers, permanent onchain references, and configurable access control. -The simplest agent integration is read-only: query a Lab's data room for files, download them, perform analysis, and present results. This requires only a consumer credential. Querying `labWithDataRoomAndFiles` gives you the complete file list with download URLs, content types, and encryption metadata. For encrypted files, the agent calls `decryptDataKey` with its service token; the backend evaluates the file's onchain access conditions and, if satisfied, returns the plaintext DEK. Access commonly resolves through a [Viewer or Contributor role grant](../technical-deep-dive/roles-and-permissions.md) on the Lab β€” granted by the Lab owner with `isAgent = true` and a bounded `expiry` matching the agent's session-key lifetime. +The simplest agent integration is read-only: query a Lab's data room for files, download them, perform analysis, and present results. This requires only a consumer credential. Querying `labWithDataRoomAndFiles` gives you the complete file list with download URLs, content types, and encryption metadata. For encrypted files, the agent calls `decryptDataKey` with its service token; the backend checks the Lab's `DECRYPT_FILES` capability, then evaluates the file's own onchain access conditions, and returns the plaintext DEK once both pass. Access commonly resolves through a [Viewer or Contributor role grant](../technical-deep-dive/roles-and-permissions.md) on the Lab β€” granted by the Lab owner with `isAgent = true` and a bounded `expiry` matching the agent's session-key lifetime β€” or through the Lab's [access policy](../api-reference/labs-api/access-policies.md) if it opens `DECRYPT_FILES` to non-members. A write-enabled agent goes further: it reads data, performs analysis, and writes results back as new files in the Lab's data room. This requires both a consumer credential and a service token. Two paths to a service token: * **Long-lived service token** β€” mint one via the `generateServiceToken` mutation (wallet signature or Privy session), or contact the Molecule team. The token is a JWT tied to your wallet; write authorization is resolved from that wallet's onchain role on the target Lab. * **Pay-per-call via the** [**x402 Gateway**](../api-reference/x402-gateway.md) β€” for agents that serve external users, charge per request, or don't have pre-provisioned credentials. The gateway settles a USDC payment on Base per call and mints a short-lived (default 5-minute) service token scoped to one mutation. Available for `initiateCreateOrUpdateFile`, `finishCreateOrUpdateFile`, `createAnnouncement`, `createLab`, `generateDataEncryptionKey`, and `decryptDataKey`. -The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey` β€” the backend returns a one-shot plaintext DEK (plus its encrypted form) you use to encrypt locally before upload, attaching the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. +Write access does not always require a role grant. A Lab owner can open specific data-room actions β€” most commonly contributing new files β€” to any authenticated caller, to a submission deadline, or to wallets satisfying an onchain condition such as holding a token. On such a Lab an agent contributes with nothing but its own service token, which is the path for open calls for data and bounty-style submissions; the owner can close the Lab again at any time, and the agent's wallet is recorded as the author either way. See [Access Policies](../api-reference/labs-api/access-policies.md) for how to read a Lab's policy before attempting a write. + +The three-step upload flow (initiate, PUT, finalize) lets you write any file type with metadata including descriptions, tags, categories, and searchable content text. If the file should be confidential, first request a key via `generateDataEncryptionKey(oclId, accessControlConditions)` β€” the backend returns a one-shot plaintext DEK (plus its encrypted form) bound to that lab and condition array, which you use to encrypt locally before upload, attaching the same `accessControlConditions` and the encryption metadata on finish. Every file the agent writes becomes a permanent, versioned record in the Lab's history. For agents that need to operate autonomously within an Onchain Lab's smart contract context β€” executing treasury operations, managing permissions, or interacting with DeFi protocols β€” the path is through executor modules. The agent's logic is deployed as a module contract, attested in the ERC-7484 Registry, and installed on the target Lab by its owner. At that point, the agent can call executeFromExecutor to perform Lab actions within whatever boundaries the module enforces (spending limits, allowed function selectors, time windows). The V3 whitepaper describes two operational modes for this: Human-Directed (agents assist, humans approve) and Fully Autonomous (agents operate independently within onchain constraints). diff --git a/user-guides/scientists-researchers.md b/user-guides/scientists-researchers.md index a9c8ccf..5586c90 100644 --- a/user-guides/scientists-researchers.md +++ b/user-guides/scientists-researchers.md @@ -24,6 +24,8 @@ Both pathways carry the same security and access rules, so working through the A Files are encrypted before they reach Molecule's servers, and only named people can open them, giving a researcher the ease of a cloud drive with security a drive does not provide. Access is set per file, so raw data can be shared publicly to build credibility while premium datasets and sensitive pre-publication results stay gated until the researcher chooses to release them. The Lab carries a public reporting surface alongside the confidential workspace, and the owner decides what stays private and what becomes visible. Collaborators join as a contributor or viewer. +A Lab can also be opened past its named collaborators when the work calls for it. An owner can allow anyone signed in to contribute files β€” permanently, until a deadline, or only while an onchain condition holds β€” which turns a Lab into an open call for datasets or a bounty hub without giving contributors any further reach. Contributions are still attributed to the contributor's account, existing files cannot be altered or deleted by an outside contributor, and the Lab can be closed back down at any point. + ### Building a Record Worth Showing Files and the meaningful actions around them are timestamped and presented as a clean, credible history, so where a cloud drive only stores files, a Lab turns ongoing work into a record a funder can rely on. That record carries direct commercial weight. A funder can perform due diligence by inspecting how much data a Lab has generated and how its funding was deployed, seeing at a glance the progress the work has made, which replaces the opaque and fragmented diligence process common in early-stage biotech with something auditable. Labs that show consistent progress and responsible treasury management build the kind of track record that attracts more funding and better collaborators, creating a flywheel effect.