Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
02d285e
test(framework): DA-11 tiny frozen-backbone + linear-head fixture
anurag2796 Jul 11, 2026
b50b672
feat(framework): DA-11 trainable-subset key-guard (fail-loud on froze…
anurag2796 Jul 11, 2026
c502311
feat(framework): DA-11 non-strict reconstruction of the aggregated tr…
anurag2796 Jul 11, 2026
4404e73
test(framework): DA-11 vertical slice - trainable-subset FedAvg round…
anurag2796 Jul 11, 2026
ea775b9
fix(framework): DA-11 guard clients pre-aggregation + shape-aware Sub…
anurag2796 Jul 11, 2026
e5ce6f5
feat(framework): DA-11 frozen_state manifest (F32 complement of train…
anurag2796 Jul 11, 2026
3db350c
feat(framework): DA-11 deterministic content-addressed backbone seria…
anurag2796 Jul 11, 2026
0e2390b
feat(framework): DA-11 content-addressed backbone cache (fetch-once, …
anurag2796 Jul 11, 2026
664f5ea
feat(framework): DA-11 non-strict frozen-backbone reconstruction with…
anurag2796 Jul 11, 2026
77fc796
test(framework): DA-11 vertical slice - a fetched content-addressed b…
anurag2796 Jul 11, 2026
521c907
fix(framework): DA-11 serialize_backbone device-safe (.cpu before .nu…
anurag2796 Jul 11, 2026
9be6ab6
fix(mobile): MO-9 — stop capturing jsi::Runtime& across the worker bo…
anurag2796 Jul 11, 2026
368b53a
docs: DO-10 document the content-addressed artifact registry, retire …
anurag2796 Jul 11, 2026
7280742
fix(backend): SE-16 participant-gate the artifact read endpoints (BOLA)
anurag2796 Jul 14, 2026
0586f10
fix(framework): SE-15 bind the FL connection token to one client_id (…
anurag2796 Jul 14, 2026
00d2d12
Merge branch 'da11-phase2a-backbone-distribution' into staging
anurag2796 Jul 14, 2026
ed80bf0
Merge branch 'se-16-artifact-participant-gate' into staging
anurag2796 Jul 14, 2026
c943a86
Merge branch 'se-15-bind-connection-token-clientid' into staging
anurag2796 Jul 14, 2026
ca8ec27
Merge branch 'todo/do10-registry-docs' into staging
anurag2796 Jul 14, 2026
9486683
Merge branch 'todo/mo9-runtime-hazard' into staging
anurag2796 Jul 14, 2026
0cf8961
fix(scripts): SE-19 stop auto-executing remote dataset code by default
anurag2796 Jul 14, 2026
8ab5762
fix(backend): SE-17 rebuild the FL-server child env from an allowlist
anurag2796 Jul 14, 2026
33bc98e
fix(backend): SE-22 raise the scripts-CI lockfile pins to the securit…
anurag2796 Jul 14, 2026
453caf4
fix(backend): SE-21/SE-24 harden the deployed-profile cookie + actuat…
anurag2796 Jul 14, 2026
d59b588
fix(backend): SE-20 fail boot closed when FL token secret equals the …
anurag2796 Jul 14, 2026
94f49c0
fix(framework): SE-18 cap streamed model uploads to stop a memory-exh…
anurag2796 Jul 14, 2026
9dededb
docs: DO-14/DO-15 correct the false "identity absent" + stale libtorc…
anurag2796 Jul 14, 2026
4d5662f
fix(framework): SE-18 add a server-side wall-clock deadline to stream…
anurag2796 Jul 14, 2026
21765fc
refactor(backend): DA-12 rename the legacy "flower" package/class -> …
anurag2796 Jul 14, 2026
15372df
feat(framework): MO-19 fail loud on a DeComFL client/server trainable…
anurag2796 Jul 14, 2026
644a790
ci(security): allowlist framework test-fixtures in gitleaks
anurag2796 Jul 15, 2026
e2b5cc2
fix(backend): SE-22 keep cryptography flwr-compatible (unblock the pi…
anurag2796 Jul 15, 2026
c69d5ad
fix(framework): pin torch==2.12.0 so CI matches the golden-fixture to…
anurag2796 Jul 15, 2026
e77af59
ci(mobile): fix python-parity job — install pytest-cov + --no-cov the…
anurag2796 Jul 15, 2026
8c378f5
ci: unblock framework (drop unused torchvision) + backend-scripts (ad…
anurag2796 Jul 15, 2026
6a0b8f0
test(scripts): expose framework/src to subprocesses via PYTHONPATH (c…
anurag2796 Jul 15, 2026
6eb30ad
ci(mobile): python-parity needs the framework deps (grpc) to import f…
anurag2796 Jul 15, 2026
cdc0e88
docs+test: address Copilot review — hoist OrderedDict import; fix 2 m…
anurag2796 Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ jobs:
# and `scikit-learn` (framework/requirements.txt) — mirror that here. Without these the
# suite errors out during collection (5 collection errors), a false red the moment the
# scripts change. They resolve against the already-installed torch, so torch is untouched.
pip install -r backend/fl-platform-api/requirements.txt pytest "peft>=0.11" scikit-learn
# PyJWT: the FL-server import chain (security/token_verify.py -> `import jwt`, pulled in by
# the connection-token interceptor) needs it; framework/requirements.txt declares it, but
# this job installs the backend lockfile + uses the framework via sys.path, so add it here
# (mirrors framework/requirements.txt's `PyJWT>=2.8,<3`), like peft/scikit-learn above.
pip install -r backend/fl-platform-api/requirements.txt pytest "peft>=0.11" scikit-learn "PyJWT>=2.8,<3"
# TE-10: the skip-integrity guard in tests/conftest.py fails this job if any test is
# SKIPPED (this suite allowlists no skip reasons; `-m "not slow"` deselection is not a skip).
- name: pytest (FL trainer scripts; pytest.ini deselects -m slow)
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,18 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install --index-url https://download.pytorch.org/whl/cpu "torch==${TORCH_VERSION}"
pip install numpy pytest
# test_perturbation.py imports `fedlearn`, whose package __init__ eagerly pulls in the gRPC
# server (grpc/protobuf) and token-verify (PyJWT) — so the parity job needs the framework's
# full dependency set, not just numpy. requirements.txt pins the matching torch==2.12.0
# (already installed above) and no torchvision, so this resolves cleanly.
# pytest-cov: framework/pytest.ini addopts include --cov=fedlearn (TE-11), so pytest errors
# with "unrecognized arguments: --cov" unless it is installed — even for this subset run.
pip install -r framework/requirements.txt pytest pytest-cov
- name: Run perturbation parity tests
working-directory: framework
run: PYTHONPATH=src pytest tests/test_perturbation.py -v
# --no-cov: this runs a single test file, not the full suite, so coverage would trip the
# --cov-fail-under floor by design (see the pytest.ini note). Disable it for the subset.
run: PYTHONPATH=src pytest tests/test_perturbation.py -v --no-cov

# ---- C++ parity gate: the gtests compare the C++ core to the Python golden fixture ----
# The core links the ExecuTorch runtime (no libtorch/ATen). The ET pip package ships headers but
Expand Down
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ description = "Public test/dev-only fixture credentials (never used outside test
paths = [
'''.*/src/test/.*''',
'''.*/application-(test|dev)\.properties$''',
# Framework test fixtures — e.g. framework/tests/fixtures/golden_connection_token.json, a
# golden token signed with the dummy secret base64("fedlearn-golden-token-secret-32b") used
# by the cross-language connection-token parity tests. Test-only, never a real credential.
'''.*/tests/fixtures/.*''',
]
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ FedLearn-Platform/
│ │ ├── repository/ # JPA repositories
│ │ ├── model/ # Entities
│ │ ├── security/ # JWT provider
│ │ └── flower/ # FlowerServerManager
│ │ └── orchestration/ # FlServerManager (renamed from flower/, DA-12)
│ ├── src/main/resources/
│ │ └── scripts/ # Python FL server scripts
│ └── README.md # Backend documentation
Expand All @@ -308,7 +308,7 @@ FedLearn-Platform/
│ ├── src/ # main / preload / renderer
│ └── README.md # Desktop documentation
├── mobile_client/ # React Native client + native C++ (libtorch) core
├── mobile_client/ # React Native client + native C++ (ExecuTorch) core
│ ├── proto/ # Byte-mirror of /proto (checked in CI)
│ └── README.md # Mobile documentation
Expand Down Expand Up @@ -516,7 +516,7 @@ Live at **https://fedlearn.duckdns.org**. Deploy procedure: [`docs/guides/aws_de
- Let's Encrypt certbot for auto-renewing TLS
- PostgreSQL 16 (local Docker Compose or host package) on the EC2 host, data dir EBS-backed across reboots
- Spring Boot as a systemd service (`fedlearn.service`)
- Python FL servers spawned by `FlowerServerManager`
- Python FL servers spawned by `FlServerManager`

Required env vars (set in `/etc/systemd/system/fedlearn.service`):

Expand Down
8 changes: 4 additions & 4 deletions backend/fl-platform-api/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
accelerate==1.10.0
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiohttp>=3.14.0,<4.0 # SE-22: CVE-2026-34993/47265 (RCE) + DoS/smuggling fixes (was 3.12.15)
aiohttp-cors==0.8.1
aiosignal==1.4.0
annotated-types==0.7.0
Expand All @@ -13,7 +13,7 @@ click==8.1.8
colorama==0.4.6
colorful==0.5.7
contourpy==1.3.3
cryptography==44.0.3
cryptography==44.0.3 # SE-22: the framework floor is >=46.0.6, but this lockfile uses flwr-datasets (FederatedDataset in fl_server.py/client.py) -> flwr 1.20.0, which pins cryptography<45.0.0. The 46.x floor is therefore UNREACHABLE here until flwr-datasets is dropped/updated; 44.0.3 is the newest flwr-compatible pin. Tracked as the SE-22 residual.
cycler==0.12.1
datasets==3.1.0
dill==0.3.8
Expand Down Expand Up @@ -58,7 +58,7 @@ opentelemetry-semantic-conventions==0.57b0
packaging==25.0
pandas==2.3.1
pathspec==0.12.1
pillow==11.0.0
pillow>=12.2.0,<13.0 # SE-22: PYSEC-2026-165 + CVE-2026-40192/42309/42310/42311 (was 11.0.0)
platformdirs==4.3.8
prometheus_client==0.22.1
propcache==0.3.2
Expand All @@ -82,7 +82,7 @@ PyYAML==6.0.2
ray==2.48.0
referencing==0.36.2
regex==2025.7.34
requests==2.32.4
requests>=2.33.0,<3.0 # SE-22: CVE-2026-25645 (was 2.32.4)
rich==13.9.4
rpds-py==0.27.0
rsa==4.9.1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.federated.fl_platform_api.bootstrap;

import com.federated.fl_platform_api.flower.FlowerServerManager;
import com.federated.fl_platform_api.orchestration.FlServerManager;
import com.federated.fl_platform_api.model.Run;
import com.federated.fl_platform_api.model.RunStatus;
import com.federated.fl_platform_api.repository.ProjectRepository;
Expand All @@ -23,11 +23,11 @@
/**
* BA-3: reconciles FL-server processes against persisted run state on backend startup.
*
* <p>FL servers are spawned as child OS processes and tracked only in {@link FlowerServerManager}'s
* <p>FL servers are spawned as child OS processes and tracked only in {@link FlServerManager}'s
* in-memory map, so a backend crash orphans them: children keep running (holding gRPC ports) while
* their runs sit forever in a non-terminal state with no handle to stop them. On boot this loads every
* still-in-flight run and, using the PID + OS start-instant recorded at spawn (see
* {@link FlowerServerManager#recordProcessIdentity}):
* {@link FlServerManager#recordProcessIdentity}):
* <ul>
* <li><b>re-adopts</b> a run whose recorded PID is still live and whose start-instant matches — the
* server survived the restart, so it is tracked again and a later stop can terminate it;</li>
Expand Down Expand Up @@ -61,7 +61,7 @@ public class StartupReconciler implements HealthIndicator {
private final RunRepository runRepository;
private final ProjectRepository projectRepository;
private final RunService runService;
private final FlowerServerManager serverManager;
private final FlServerManager serverManager;
private final ProcessProbe processProbe;
private final RunTokenRegistry runTokenRegistry;

Expand All @@ -73,7 +73,7 @@ public class StartupReconciler implements HealthIndicator {
private volatile ReconciliationResult lastResult;

public StartupReconciler(RunRepository runRepository, ProjectRepository projectRepository,
RunService runService, FlowerServerManager serverManager,
RunService runService, FlServerManager serverManager,
ProcessProbe processProbe, RunTokenRegistry runTokenRegistry) {
this.runRepository = runRepository;
this.projectRepository = projectRepository;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.federated.fl_platform_api.config;

import jakarta.annotation.PostConstruct;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

/**
* SE-20: fail the boot closed when the FL connection-token signing secret is the SAME as the web-auth
* JWT secret on a DEPLOYED profile.
*
* <p>{@code app.fl.token-secret} defaults to {@code app.jwt.secret} ({@code
* ${APP_FL_TOKEN_SECRET:${app.jwt.secret}}}) for local backward-compat. But the FL server is
* network-facing and holds the FL secret (SE-1/SE-7); if that secret equals the web-auth key, a
* compromise of the FL server can mint valid web/admin sessions — defeating the SE-7/SE-17
* trust-domain isolation (which deliberately keeps {@code APP_JWT_SECRET} out of the FL child). On
* {@code ec2demo}/{@code production} we therefore require a DISTINCT {@code APP_FL_TOKEN_SECRET} and
* refuse to start otherwise. Off those profiles (dev/test/base) the fallback stays allowed so a local
* run needs no extra secret.
*
* <p>Note: this is the "boot check they differ" half of finding #7. The complementary web-JWT
* audience/type binding (so the two token classes can't be cross-presented even under a shared dev
* secret) is a deliberate, separate change to the auth hot path and is tracked as remaining.
*/
@Component
public class FlSecretDistinctnessValidator {

/** Profiles that must not share the web + FL signing secret. */
static final Set<String> DEPLOYED_PROFILES = Set.of("ec2demo", "production");

private final String jwtSecret;
private final String flTokenSecret;
private final Environment environment;

public FlSecretDistinctnessValidator(
@Value("${app.jwt.secret}") String jwtSecret,
@Value("${app.fl.token-secret}") String flTokenSecret,
Environment environment) {
this.jwtSecret = jwtSecret;
this.flTokenSecret = flTokenSecret;
this.environment = environment;
}

@PostConstruct
void validateOnBoot() {
check(jwtSecret, flTokenSecret, environment.getActiveProfiles());
}

/**
* Throw when a deployed profile is active and the two secrets resolve equal. Package-private +
* static so the policy is unit-testable without a Spring context.
*/
static void check(String jwtSecret, String flTokenSecret, String[] activeProfiles) {
boolean deployed = Arrays.stream(activeProfiles).anyMatch(DEPLOYED_PROFILES::contains);
if (deployed && Objects.equals(jwtSecret, flTokenSecret)) {
throw new IllegalStateException(
"SE-20: app.fl.token-secret must be DISTINCT from app.jwt.secret on a deployed "
+ "profile (ec2demo/production). Set a dedicated APP_FL_TOKEN_SECRET so a "
+ "compromise of the network-facing FL server cannot forge web/admin "
+ "sessions. It currently falls back to the web-auth secret.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import com.federated.fl_platform_api.dto.ArtifactDto;
import com.federated.fl_platform_api.model.ArtifactKind;
import com.federated.fl_platform_api.model.ModelArtifact;
import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactBlobStore;
import com.federated.fl_platform_api.service.AuthorizationService;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -41,11 +44,16 @@ public class ArtifactController {
private final ModelArtifactRepository artifacts;
private final ArtifactBlobStore blobStore;
private final OrgScope orgScope;
private final AuthorizationService authz;
private final ProjectRepository projects;

public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore blobStore, OrgScope orgScope) {
public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore blobStore, OrgScope orgScope,
AuthorizationService authz, ProjectRepository projects) {
this.artifacts = artifacts;
this.blobStore = blobStore;
this.orgScope = orgScope;
this.authz = authz;
this.projects = projects;
}

/**
Expand All @@ -56,7 +64,9 @@ public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore b
@GetMapping
public java.util.List<ArtifactDto> list(@RequestParam UUID projectId) {
return artifacts.findByProjectId(projectId).stream()
.filter(a -> orgScope.allows(a.getOrgId()))
// SE-16: org-visible AND (published OR participant). A non-participant sees only the
// project's PUBLISHED rows (the marketplace items), never its private weights.
.filter(this::mayRead)
.sorted(java.util.Comparator.comparing(ModelArtifact::getCreatedAt,
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())).reversed())
.map(ArtifactDto::from)
Expand Down Expand Up @@ -93,14 +103,34 @@ public ResponseEntity<ArtifactDto> latest(
@RequestParam UUID projectId,
@RequestParam(defaultValue = "FULL_CHECKPOINT") ArtifactKind kind) {
ModelArtifact a = artifacts.findFirstByProjectIdAndKindOrderByCreatedAtDesc(projectId, kind).orElse(null);
if (a == null || !orgScope.allows(a.getOrgId())) {
if (a == null || !mayRead(a)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(ArtifactDto.from(a));
}

private ModelArtifact visibleOr404(UUID id) {
ModelArtifact a = artifacts.findById(id).orElse(null);
return (a != null && orgScope.allows(a.getOrgId())) ? a : null;
return (a != null && mayRead(a)) ? a : null;
}

/**
* SE-16 read gate. An artifact is readable iff it is in the caller's org scope AND one of:
* it is org-shared with no owning project ({@code projectId == null}, e.g. a {@code BASE_REF});
* it has been explicitly PUBLISHED to the org marketplace (FE-12); or the caller is a participant
* (owner/member/client/admin) of its project. Otherwise a non-participant reads it as absent, so a
* project's private model weights — and the mere existence of the project — never leak, while the
* intentional publish-to-share flow keeps working. Mirrors the gate the rest of the project read
* surface applies (results, logs, STOMP).
*/
private boolean mayRead(ModelArtifact a) {
if (!orgScope.allows(a.getOrgId())) {
return false; // tenant isolation
}
if (a.getProjectId() == null || a.isPublished()) {
return true; // org-shared base, or explicitly published to the marketplace
}
Project p = projects.findById(a.getProjectId()).orElse(null);
return p != null && authz.isParticipant(p);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.federated.fl_platform_api.controller;

import com.federated.fl_platform_api.model.ModelArtifact;
import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactRegistryService;
import com.federated.fl_platform_api.service.AuthorizationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -27,27 +30,50 @@ public class ArtifactLineageController {
private final ArtifactRegistryService registry;
private final ModelArtifactRepository artifacts;
private final OrgScope orgScope;
private final AuthorizationService authz;
private final ProjectRepository projects;

public ArtifactLineageController(ArtifactRegistryService registry,
ModelArtifactRepository artifacts,
OrgScope orgScope) {
OrgScope orgScope,
AuthorizationService authz,
ProjectRepository projects) {
this.registry = registry;
this.artifacts = artifacts;
this.orgScope = orgScope;
this.authz = authz;
this.projects = projects;
}

@GetMapping("/{id}/lineage")
public ResponseEntity<List<Map<String, Object>>> lineage(@PathVariable UUID id) {
ModelArtifact target = artifacts.findById(id).orElse(null);
if (target == null || !orgScope.allows(target.getOrgId())) {
return ResponseEntity.notFound().build(); // 404 for both — no cross-org existence leak
if (target == null || !mayRead(target)) {
return ResponseEntity.notFound().build(); // 404 for all — no cross-org/cross-project leak (SE-16)
}
List<Map<String, Object>> chain = registry.getLineageChain(id).stream()
.map(ArtifactLineageController::toDto)
.toList();
return ResponseEntity.ok(chain);
}

/**
* SE-16 read gate (same rule as {@code ArtifactController}): readable iff org-visible AND
* (no owning project — a {@code BASE_REF}; OR explicitly published to the marketplace, FE-12; OR
* the caller is a participant of its project). A non-participant reads a private artifact's
* lineage — and the base/license provenance it exposes — as absent, so nothing leaks.
*/
private boolean mayRead(ModelArtifact a) {
if (!orgScope.allows(a.getOrgId())) {
return false;
}
if (a.getProjectId() == null || a.isPublished()) {
return true;
}
Project p = projects.findById(a.getProjectId()).orElse(null);
return p != null && authz.isParticipant(p);
}

private static Map<String, Object> toDto(ModelArtifact a) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", a.getId().toString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ public void setModelPublishedAt(java.time.Instant modelPublishedAt) {
public void setRequirementsOverride(com.federated.fl_platform_api.dto.DeviceRequirements requirementsOverride) { this.requirementsOverride = requirementsOverride; }

// SE-11: run-level DP policy (V17). A regulated project may not start a run unless DP is
// enabled with a complete config (enforced at the FlowerServerManager start gate); when
// enabled with a complete config (enforced at the FlServerManager start gate); when
// dp_enabled, the three knobs flow to the FL server as --dp-* flags. Knobs are nullable by
// design: a non-DP project carries no config.

Expand Down
Loading
Loading