Skip to content

Commit d84eb1f

Browse files
committed
Checkpoint: commit all working directory changes
1 parent 19ec1fb commit d84eb1f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2565
-374
lines changed

ARCHITECTURE_GUIDANCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Queue movement is explicit. Material lineage is explicit. Atlas identity linkage
4040
- Atlas sends accepted material into Bloom through explicit APIs.
4141
- Bloom stores Atlas linkage through external-object references.
4242
- Bloom preserves lineage from specimen and container to run.
43-
- Bloom resolves `run_euid + index_string` to Atlas tenant, order, and TRF.test EUIDs.
43+
- Bloom resolves `run_euid + flowcell_id + lane + library_barcode` to Atlas tenant, order, and TRF.test EUIDs.
4444
- Public APIs use EUIDs only.
4545

4646
## Non-Goals

ATLAS_BLOOM_API_GUIDANCE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ Token management endpoints:
4040
Recommended token scope for Atlas write flows:
4141
- `internal_rw` (or `admin`)
4242

43+
Atlas integration config fields that must be set in Atlas:
44+
45+
1. `bloom_base_url`
46+
2. `bloom_api_key_secret_ref`
47+
3. `bloom_webhook_secret_ref`
48+
49+
Field meanings:
50+
51+
1. `bloom_base_url`: Bloom API base URL Atlas calls.
52+
2. `bloom_api_key_secret_ref`: Atlas secret reference for the Bloom bearer token.
53+
3. `bloom_webhook_secret_ref`: Atlas secret reference used to verify Bloom webhook signatures.
54+
4355
## 2.1 Atlas Lookup Endpoints (Bloom Validation)
4456

4557
Bloom should validate Atlas references using Atlas integration lookup routes first:

bloom_lims/api/v1/__init__.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@
3333
from .templates import router as templates_router
3434
from .tracking import router as tracking_router
3535
from .user_api_tokens import router as user_api_tokens_router
36-
from .workflows import router as workflows_router
37-
from .worksets import router as worksets_router
3836

3937
# Main v1 router
4038
router = APIRouter(prefix="/api/v1", tags=["API v1"])
@@ -56,9 +54,7 @@
5654
router.include_router(search_router)
5755
router.include_router(search_v2_router)
5856
router.include_router(object_creation_router)
59-
router.include_router(worksets_router)
6057
router.include_router(tracking_router)
61-
router.include_router(workflows_router)
6258
router.include_router(user_api_tokens_router)
6359
router.include_router(admin_auth_router)
6460
router.include_router(external_specimens_router)
@@ -90,8 +86,6 @@ async def api_v1_info():
9086
"search": "/api/v1/search",
9187
"search_v2": "/api/v1/search/v2",
9288
"object_creation": "/api/v1/object-creation",
93-
"worksets": "/api/v1/worksets",
94-
"workflows": "/api/v1/workflows",
9589
"user_tokens": "/api/v1/user-tokens",
9690
"admin_auth": "/api/v1/admin/groups",
9791
"external_specimens": "/api/v1/external/specimens",

bloom_lims/api/v1/admin_auth.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from bloom_lims.api.v1.dependencies import APIUser, require_admin
99
from bloom_lims.auth.services.groups import GroupService
10-
from bloom_lims.auth.services.user_api_tokens import UserAPITokenService
10+
from bloom_lims.auth.services.user_api_tokens import TokenCreateInput, UserAPITokenService
1111
from bloom_lims.db import BLOOMdb3
1212

1313
router = APIRouter(prefix="/admin", tags=["Admin Auth"])
@@ -17,6 +17,14 @@ class GroupMemberAddRequest(BaseModel):
1717
user_id: str = Field(..., min_length=1)
1818

1919

20+
class AdminIssueTokenRequest(BaseModel):
21+
user_id: str = Field(..., min_length=1)
22+
token_name: str = Field(..., min_length=3, max_length=120)
23+
scope: str = Field(default="internal_ro")
24+
expires_in_days: int = Field(default=30, ge=1, le=3650)
25+
note: str | None = None
26+
27+
2028
def _require_id(value: str | None, *, field_name: str) -> str:
2129
normalized = str(value or "").strip()
2230
if not normalized:
@@ -88,17 +96,35 @@ async def add_group_member(
8896
bdb = BLOOMdb3(app_username=user.email)
8997
try:
9098
service = GroupService(bdb.session)
99+
existing = {
100+
str(member.user_id): member
101+
for member in service.list_group_members(group_code=group_code)
102+
}.get(member_user_id)
103+
91104
member = service.add_user_to_group(
92105
group_code=group_code,
93106
user_id=member_user_id,
94107
added_by=actor_user_id,
95108
)
109+
110+
if existing is None:
111+
result = "added"
112+
message = f"Added {member_user_id} to {group_code}"
113+
elif existing.is_active:
114+
result = "exists"
115+
message = f"{member_user_id} is already active in {group_code}"
116+
else:
117+
result = "reactivated"
118+
message = f"Reactivated {member_user_id} membership in {group_code}"
119+
96120
return {
97121
"id": str(member.id),
98122
"group_id": str(member.group_id),
99123
"group_code": member.group_code,
100124
"user_id": str(member.user_id),
101125
"is_active": member.is_active,
126+
"result": result,
127+
"message": message,
102128
}
103129
except ValueError as exc:
104130
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -164,6 +190,49 @@ async def list_admin_user_tokens(user: APIUser = Depends(require_admin)):
164190
bdb.close()
165191

166192

193+
@router.post("/user-tokens/issue")
194+
async def issue_admin_user_token(
195+
payload: AdminIssueTokenRequest,
196+
user: APIUser = Depends(require_admin),
197+
):
198+
actor_user_id = _require_id(user.user_id, field_name="authenticated user_id")
199+
owner_user_id = _require_id(payload.user_id, field_name="user_id")
200+
bdb = BLOOMdb3(app_username=user.email)
201+
try:
202+
service = UserAPITokenService(bdb.session)
203+
service.groups.ensure_system_groups()
204+
created = service.create_token(
205+
owner_user_id=owner_user_id,
206+
actor_user_id=actor_user_id,
207+
actor_roles=user.roles,
208+
actor_groups=user.groups,
209+
payload=TokenCreateInput(
210+
token_name=payload.token_name,
211+
scope=payload.scope,
212+
expires_in_days=payload.expires_in_days,
213+
note=payload.note,
214+
),
215+
)
216+
return {
217+
"token": {
218+
"token_id": str(created.token.id),
219+
"user_id": str(created.token.user_id),
220+
"token_name": created.token.token_name,
221+
"token_prefix": created.token.token_prefix,
222+
"scope": created.token.scope,
223+
"status": created.revision.status,
224+
"expires_at": created.revision.expires_at.isoformat(),
225+
"created_at": created.token.created_at.isoformat() if created.token.created_at else None,
226+
},
227+
"plaintext_token": created.plaintext_token,
228+
"message": "Store this token now; it will not be shown again.",
229+
}
230+
except PermissionError as exc:
231+
raise HTTPException(status_code=403, detail=str(exc)) from exc
232+
finally:
233+
bdb.close()
234+
235+
167236
@router.delete("/user-tokens/{token_id}")
168237
async def revoke_admin_user_token(
169238
token_id: str,
@@ -224,4 +293,3 @@ async def get_admin_token_usage(
224293
}
225294
finally:
226295
bdb.close()
227-

bloom_lims/api/v1/atlas_bridge.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fastapi import APIRouter, Depends, Header, HTTPException
88

9-
from bloom_lims.api.v1.dependencies import APIUser, require_external_token_auth
9+
from bloom_lims.api.v1.dependencies import APIUser, require_external_atlas_api_enabled
1010
from bloom_lims.auth.rbac import Permission
1111
from bloom_lims.integrations.atlas.service import AtlasDependencyError, AtlasService
1212
from bloom_lims.schemas.atlas_bridge import (
@@ -20,7 +20,7 @@
2020
router = APIRouter(prefix="/external/atlas", tags=["External Atlas"])
2121

2222

23-
def require_external_write(user: APIUser = Depends(require_external_token_auth)) -> APIUser:
23+
def require_external_write(user: APIUser = Depends(require_external_atlas_api_enabled)) -> APIUser:
2424
if not user.has_permission(Permission.BLOOM_WRITE):
2525
raise HTTPException(status_code=403, detail="Write permission required")
2626
return user

bloom_lims/api/v1/beta_lab.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
from fastapi import APIRouter, Depends, Header, HTTPException, Query
88

9-
from bloom_lims.api.v1.dependencies import APIUser, require_external_token_auth
9+
from bloom_lims.api.v1.dependencies import (
10+
APIUser,
11+
require_external_atlas_api_enabled,
12+
require_external_ursa_api_enabled,
13+
)
1014
from bloom_lims.auth.rbac import Permission
1115
from bloom_lims.domain.beta_lab import BetaLabService
1216
from bloom_lims.schemas.beta_lab import (
@@ -33,13 +37,21 @@
3337

3438

3539
def require_external_write(
36-
user: APIUser = Depends(require_external_token_auth),
40+
user: APIUser = Depends(require_external_atlas_api_enabled),
3741
) -> APIUser:
3842
if not user.has_permission(Permission.BLOOM_WRITE):
3943
raise HTTPException(status_code=403, detail="Write permission required")
4044
return user
4145

4246

47+
def require_external_ursa_read(
48+
user: APIUser = Depends(require_external_ursa_api_enabled),
49+
) -> APIUser:
50+
if not user.has_permission(Permission.BLOOM_READ):
51+
raise HTTPException(status_code=403, detail="Read permission required")
52+
return user
53+
54+
4355
def _status_for_value_error(exc: ValueError) -> int:
4456
detail = str(exc).lower()
4557
if "not found" in detail:
@@ -214,10 +226,9 @@ async def resolve_run_assignment(
214226
flowcell_id: str = Query(...),
215227
lane: str = Query(...),
216228
library_barcode: str = Query(...),
217-
user: APIUser = Depends(require_external_token_auth),
229+
user: APIUser = Depends(require_external_ursa_read),
218230
):
219-
_ = user
220-
service = BetaLabService(app_username="atlas-beta-resolver")
231+
service = BetaLabService(app_username=user.email)
221232
try:
222233
return service.resolve_run_assignment(
223234
run_euid=run_euid,

bloom_lims/api/v1/dependencies.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from bloom_lims.auth.rbac import (
1313
API_ACCESS_GROUP,
14+
ENABLE_ATLAS_API_GROUP,
15+
ENABLE_URSA_API_GROUP,
1416
Permission,
1517
Role,
1618
effective_permissions,
@@ -273,7 +275,12 @@ async def get_api_user(
273275
email="api-dev@daylilyinformatics.com",
274276
user_id="dev-bypass-admin",
275277
roles=[Role.ADMIN.value],
276-
groups=[Role.ADMIN.value, API_ACCESS_GROUP],
278+
groups=[
279+
Role.ADMIN.value,
280+
API_ACCESS_GROUP,
281+
ENABLE_ATLAS_API_GROUP,
282+
ENABLE_URSA_API_GROUP,
283+
],
277284
permissions=sorted(permission.value for permission in Permission),
278285
role=Role.ADMIN.value,
279286
auth_source="dev_bypass",
@@ -361,6 +368,29 @@ async def require_external_token_auth(user: APIUser = Depends(get_api_user)) ->
361368
return user
362369

363370

371+
def _require_group_membership(user: APIUser, group_code: str) -> APIUser:
372+
normalized_required = str(group_code or "").strip().upper()
373+
groups = {str(group or "").strip().upper() for group in user.groups}
374+
if normalized_required not in groups:
375+
raise HTTPException(
376+
status_code=403,
377+
detail=f"Group membership required: {normalized_required}",
378+
)
379+
return user
380+
381+
382+
async def require_external_atlas_api_enabled(
383+
user: APIUser = Depends(require_external_token_auth),
384+
) -> APIUser:
385+
return _require_group_membership(user, ENABLE_ATLAS_API_GROUP)
386+
387+
388+
async def require_external_ursa_api_enabled(
389+
user: APIUser = Depends(require_external_token_auth),
390+
) -> APIUser:
391+
return _require_group_membership(user, ENABLE_URSA_API_GROUP)
392+
393+
364394
def require_permission(permission: Permission | str):
365395
async def _check(user: APIUser = Depends(get_api_user)) -> APIUser:
366396
if not user.has_permission(permission):

bloom_lims/api/v1/external_specimens.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fastapi import APIRouter, Depends, Header, HTTPException, Query
88

9-
from bloom_lims.api.v1.dependencies import APIUser, require_external_token_auth
9+
from bloom_lims.api.v1.dependencies import APIUser, require_external_atlas_api_enabled
1010
from bloom_lims.bobjs import BloomObj
1111
from bloom_lims.db import BLOOMdb3
1212
from bloom_lims.domain.external_specimens import ExternalSpecimenService
@@ -85,7 +85,7 @@ def _track_specimen_interaction(
8585
@router.post("", response_model=ExternalSpecimenResponse)
8686
async def create_external_specimen(
8787
payload: ExternalSpecimenCreateRequest,
88-
user: APIUser = Depends(require_external_token_auth),
88+
user: APIUser = Depends(require_external_atlas_api_enabled),
8989
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
9090
):
9191
service = ExternalSpecimenService(app_username=user.email)
@@ -131,7 +131,7 @@ async def find_external_specimens_by_reference(
131131
atlas_tenant_id: str | None = Query(None),
132132
atlas_trf_euid: str | None = Query(None),
133133
atlas_test_euid: str | None = Query(None),
134-
user: APIUser = Depends(require_external_token_auth),
134+
user: APIUser = Depends(require_external_atlas_api_enabled),
135135
):
136136
refs = AtlasReferences(
137137
trf_euid=trf_euid,
@@ -156,7 +156,7 @@ async def find_external_specimens_by_reference(
156156
@router.get("/{specimen_euid}", response_model=ExternalSpecimenResponse)
157157
async def get_external_specimen(
158158
specimen_euid: str,
159-
user: APIUser = Depends(require_external_token_auth),
159+
user: APIUser = Depends(require_external_atlas_api_enabled),
160160
):
161161
service = ExternalSpecimenService(app_username=user.email)
162162
try:
@@ -174,7 +174,7 @@ async def get_external_specimen(
174174
async def update_external_specimen(
175175
specimen_euid: str,
176176
payload: ExternalSpecimenUpdateRequest,
177-
user: APIUser = Depends(require_external_token_auth),
177+
user: APIUser = Depends(require_external_atlas_api_enabled),
178178
):
179179
service = ExternalSpecimenService(app_username=user.email)
180180
previous_status: str | None = None

0 commit comments

Comments
 (0)