gabriel / musehub public
musehub_orgs.py python
259 lines 8.3 KB
Raw
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor ⚠ breaking 17 days ago
1 """Org identity service — create orgs, manage member_of relationships."""
2
3 import logging
4 from datetime import datetime, timezone
5
6 from sqlalchemy import select
7 from sqlalchemy.exc import IntegrityError
8 from sqlalchemy.ext.asyncio import AsyncSession
9
10 from musehub.db.musehub_identity_models import MusehubIdentity
11 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot
12 from musehub.services.musehub_auth import AuthError
13 from musehub.types.json_types import JSONObject
14
15 logger = logging.getLogger(__name__)
16
17 async def create_org(
18 session: AsyncSession,
19 *,
20 handle: str,
21 display_name: str,
22 quorum: int,
23 creator_identity_id: str,
24 ) -> MusehubIdentity:
25 """Create an org identity + identity repo.
26
27 Args:
28 session: Active async session.
29 handle: URL-safe org slug (must be unique).
30 display_name: Human-readable org name.
31 quorum: N-of-M threshold for governance operations.
32 creator_identity_id: identity_id of the user creating the org.
33
34 Returns:
35 The new MusehubIdentity row.
36
37 Raises:
38 AuthError 409 if the handle is already taken.
39 """
40 from muse.core.types import blob_id
41 from muse.plugins.identity.records import IdentityRecord, identity_path, record_to_bytes
42 from musehub.services.musehub_auth import _create_identity_repo # type: ignore[attr-defined]
43
44 now = datetime.now(timezone.utc)
45 # Org identity_id is seeded from handle + creation timestamp (no signing key).
46 identity_id = blob_id(f"org\x00{handle}\x00{now.isoformat()}".encode())
47
48 identity = MusehubIdentity(
49 identity_id=identity_id,
50 handle=handle,
51 identity_type="org",
52 display_name=display_name,
53 org_quorum=quorum,
54 tos_accepted_at=now,
55 tos_version="1.0",
56 )
57 session.add(identity)
58 try:
59 await session.flush()
60 except IntegrityError:
61 await session.rollback()
62 raise AuthError(f"Handle '{handle}' is already taken", status_code=409)
63
64 await session.commit()
65
66 # Build the org IdentityRecord (pubkey=None — org has no signing key).
67 record: IdentityRecord = {
68 "handle": handle,
69 "type": "org",
70 "pubkey": None,
71 "quorum": quorum,
72 "registered_at": now.isoformat(),
73 "metadata": {"display_name": display_name},
74 }
75
76 from musehub.services.musehub_repository import create_repo as _create_repo
77 from musehub.services.musehub_sync import commit_files_to_repo
78
79 repo_response = await _create_repo(
80 session,
81 name="identity",
82 owner=handle,
83 visibility="private",
84 owner_user_id=identity_id,
85 owner_identity_id=identity_id,
86 domain="identity",
87 description=f"Identity record for org {handle}",
88 )
89
90 repo_row = await session.get(MusehubRepo, repo_response.repo_id)
91 if repo_row is not None:
92 repo_row.domain_id = "identity"
93 await session.flush()
94
95 await commit_files_to_repo(
96 session,
97 repo_id=repo_response.repo_id,
98 branch="main",
99 files={identity_path(handle): record_to_bytes(record)},
100 message=f"identity: register org {handle}",
101 author=creator_identity_id,
102 )
103 await session.commit()
104
105 logger.info("✅ Org created: handle=%s quorum=%d", handle, quorum)
106 return identity
107
108 async def add_org_member(
109 session: AsyncSession,
110 *,
111 org_handle: str,
112 member_handle: str,
113 weight: str,
114 actor_identity_id: str,
115 ) -> None:
116 """Commit a member_of RelationshipRecord to the org's identity repo.
117
118 Args:
119 session: Active async session.
120 org_handle: The org's handle.
121 member_handle: Handle of the member to add.
122 weight: Permission level: "admin", "write", or "read".
123 actor_identity_id: identity_id of the actor (commit author).
124
125 Raises:
126 AuthError 404 if the org has no identity repo.
127 """
128 from muse.plugins.identity.records import RelationshipRecord, record_to_bytes, relationship_path
129 from musehub.services.musehub_sync import commit_files_to_repo
130
131 repo = await _get_org_identity_repo(session, org_handle)
132
133 rel: RelationshipRecord = {
134 "from_handle": member_handle,
135 "to_handle": org_handle,
136 "edge_type": "member_of",
137 "weight": weight,
138 "authorized_by": [],
139 }
140
141 await commit_files_to_repo(
142 session,
143 repo_id=repo.repo_id,
144 branch="main",
145 files={relationship_path(member_handle, "member_of", org_handle): record_to_bytes(rel)},
146 message=f"identity: {member_handle} joins {org_handle} as {weight}",
147 author=actor_identity_id,
148 )
149 await session.commit()
150
151 async def remove_org_member(
152 session: AsyncSession,
153 *,
154 org_handle: str,
155 member_handle: str,
156 actor_identity_id: str,
157 ) -> None:
158 """Remove a member from the org's identity repo (commits the deletion).
159
160 Raises:
161 AuthError 404 if org or membership not found.
162 """
163 import msgpack
164 from muse.plugins.identity.records import relationship_path
165 from musehub.services.musehub_sync import commit_files_to_repo
166
167 repo = await _get_org_identity_repo(session, org_handle)
168
169 # Verify the membership file exists in HEAD.
170 rel_path = relationship_path(member_handle, "member_of", org_handle)
171 current_manifest = await _head_manifest(session, repo)
172 if rel_path not in current_manifest:
173 raise AuthError(
174 f"'{member_handle}' is not a member of '{org_handle}'", status_code=404
175 )
176
177 await commit_files_to_repo(
178 session,
179 repo_id=repo.repo_id,
180 branch="main",
181 files={},
182 delete_paths=[rel_path],
183 message=f"identity: {member_handle} removed from {org_handle}",
184 author=actor_identity_id,
185 )
186 await session.commit()
187
188 async def list_org_members(
189 session: AsyncSession,
190 *,
191 org_handle: str,
192 ) -> list[dict]:
193 """Read all member_of relationships from the org's identity repo HEAD.
194
195 Returns:
196 List of RelationshipRecord dicts for members of this org.
197
198 Raises:
199 AuthError 404 if the org has no identity repo.
200 """
201 import json
202 import msgpack
203 from muse.plugins.identity.records import parse_relationship_path
204
205 repo = await _get_org_identity_repo(session, org_handle)
206 manifest = await _head_manifest(session, repo)
207
208 members = []
209 for path, oid in manifest.items():
210 parsed = parse_relationship_path(path)
211 if parsed is None:
212 continue
213 _, edge_type, to_handle = parsed
214 if edge_type == "member_of" and to_handle == org_handle:
215 obj = await session.get(MusehubObject, oid)
216 if obj is None:
217 continue
218 from musehub.storage.backends import read_object_bytes
219 raw = await read_object_bytes(obj, session=session)
220 if raw is None:
221 continue
222 members.append(json.loads(raw))
223
224 return members
225
226 # ── private helpers ──────────────────────────────────────────────────────────
227
228 async def _get_org_identity_repo(
229 session: AsyncSession, org_handle: str
230 ) -> MusehubRepo:
231 result = await session.execute(
232 select(MusehubRepo).where(
233 MusehubRepo.owner == org_handle,
234 MusehubRepo.slug == "identity",
235 )
236 )
237 repo = result.scalar_one_or_none()
238 if repo is None:
239 raise AuthError(f"Org '{org_handle}' not found", status_code=404)
240 return repo
241
242 async def _head_manifest(session: AsyncSession, repo: MusehubRepo) -> JSONObject:
243 import msgpack
244 branch_result = await session.execute(
245 select(MusehubBranch).where(
246 MusehubBranch.repo_id == repo.repo_id,
247 MusehubBranch.name == "main",
248 )
249 )
250 branch = branch_result.scalar_one_or_none()
251 if branch is None or branch.head_commit_id is None:
252 return {}
253 commit = await session.get(MusehubCommit, branch.head_commit_id)
254 if commit is None or commit.snapshot_id is None:
255 return {}
256 snap = await session.get(MusehubSnapshot, commit.snapshot_id)
257 if snap is None:
258 return {}
259 return msgpack.unpackb(snap.manifest_blob, raw=False)
File History 1 commit
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor 17 days ago