feat(shared-topics): GroupPubsubService + shared publish fan-out + SDK handle (D2)

The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
  trigger never fires on a per-app publish); new fan_out_shared_publish
  matches `shared = true` pubsub triggers on the resolved owning group,
  each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
  owning group (kind='topic') from cx.app_id's chain, require editor+
  (GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
  `.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
  the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.

The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-02 21:48:59 +02:00
parent 1c7e8886d8
commit 9626d15863
10 changed files with 430 additions and 43 deletions

View File

@@ -166,6 +166,10 @@ pub enum Capability {
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
GroupFilesWrite(GroupId),
/// Publish to a group-owned shared TOPIC (§11.6 D2). editor+ on the owning
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
/// publish is a write to shared state.
GroupPubsubPublish(GroupId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -238,7 +242,8 @@ impl Capability {
| Self::GroupDocsRead(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesRead(_)
| Self::GroupFilesWrite(_) => None,
| Self::GroupFilesWrite(_)
| Self::GroupPubsubPublish(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -315,6 +320,7 @@ impl Capability {
| Self::GroupKvWrite(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesWrite(_)
| Self::GroupPubsubPublish(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -536,7 +542,8 @@ async fn role_grants(
| Capability::GroupDocsRead(g)
| Capability::GroupDocsWrite(g)
| Capability::GroupFilesRead(g)
| Capability::GroupFilesWrite(g) => {
| Capability::GroupFilesWrite(g)
| Capability::GroupPubsubPublish(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
@@ -610,7 +617,8 @@ const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::GroupScriptsWrite(_)
| Capability::GroupKvWrite(_)
| Capability::GroupDocsWrite(_)
| Capability::GroupFilesWrite(_) => {
| Capability::GroupFilesWrite(_)
| Capability::GroupPubsubPublish(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the