musehub_orgs.py
python
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