Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 100 additions & 3 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# SyncFlow local stack.
#
# Required: postgres + app (the control plane needs its DB).
# The connector databases (mysql, mongodb, redis) and kafka are OPTIONAL —
# enable them when you exercise those connectors. Kafka is off by default in
# the app (syncflow.kafka.enabled); flip the app env below to use it.
#
# Run: docker compose up -d
# App: http://localhost:8080 (JWT login; default admin/admin-test-password)

services:
# ─── Required ─────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: syncflow-postgres
Expand All @@ -14,10 +25,10 @@ services:
test: ["CMD-SHELL", "pg_isready -U syncflow"]
interval: 5s
timeout: 5s
retries: 5
retries: 10

app:
image: eclipse-temurin:25-jdk-alpine
image: eclipse-temurin:25-jre-alpine
container_name: syncflow-app
depends_on:
postgres:
Expand All @@ -28,11 +39,97 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/syncflow
SPRING_DATASOURCE_USERNAME: syncflow
SPRING_DATASOURCE_PASSWORD: syncflow
SPRING_PROFILES_ACTIVE: docker
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
# JWT auth (must-change-password is on for admin-provisioned accounts).
SYNCFLOW_JWT_SECRET: ${SYNCFLOW_JWT_SECRET:-c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA==}
# Kafka transport (off by default).
SYNCFLOW_KAFKA_ENABLED: ${SYNCFLOW_KAFKA_ENABLED:-false}
SYNCFLOW_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
volumes:
- ../syncflow-api/build/libs/syncflow-api.jar:/app/app.jar
working_dir: /app
entrypoint: ["java", "--enable-preview", "-jar", "app.jar"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s

# ─── Optional: connector source databases ─────────────────────────────────
mysql:
image: mysql:8.4
container_name: syncflow-mysql
profiles: ["connectors"]
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: inventory
MYSQL_USER: syncflow
MYSQL_PASSWORD: syncflow
ports:
- "3306:3306"
command: --binlog-format=ROW --server-id=1 # required for Debezium CDC
volumes:
- mysqldata:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 10

mongodb:
image: mongo:7.0
container_name: syncflow-mongodb
profiles: ["connectors"]
ports:
- "27017:27017"
volumes:
- mongodata:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 10

redis:
image: redis:7-alpine
container_name: syncflow-redis
profiles: ["connectors"]
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10

# ─── Optional: Kafka transport ────────────────────────────────────────────
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
container_name: syncflow-zookeeper
profiles: ["kafka"]
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000

kafka:
image: confluentinc/cp-kafka:7.6.0
container_name: syncflow-kafka
profiles: ["kafka"]
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

volumes:
pgdata:
mysqldata:
mongodata:
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@EnableScheduling
public class SyncFlowApplication {

public static void main(String[] args) {
static void main(String[] args) {
SpringApplication.run(SyncFlowApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.syncflow.api.controller;

import com.syncflow.api.security.AuthService;
import com.syncflow.api.user.UserService;
import com.syncflow.api.user.entity.UserEntity;
import com.syncflow.api.user.repository.UserRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
Expand All @@ -22,27 +25,49 @@ public class AuthController {

private final AuthService authService;
private final UserRepository userRepository;
private final UserService userService;

public AuthController(AuthService authService, UserRepository userRepository) {
public AuthController(AuthService authService,
UserRepository userRepository,
UserService userService) {
this.authService = authService;
this.userRepository = userRepository;
this.userService = userService;
}

public record LoginRequest(String username, String password) {
}

public record ChangePasswordRequest(@NotBlank String newPassword) {
}

@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest req) {
try {
var token = authService.login(req.username(), req.password());
return ResponseEntity.ok(Map.of("token", token, "tokenType", "Bearer"));
var result = authService.login(req.username(), req.password());
return ResponseEntity.ok(Map.of(
"token", result.token(),
"tokenType", "Bearer",
"mustChangePassword", result.mustChangePassword()));
} catch (BadCredentialsException | DisabledException | LockedException e) {
// Credential failures and disabled/locked accounts are all a 401 — do not
// reveal which; a 500 would be wrong and leak that the account exists.
return ResponseEntity.status(401).body(Map.of("error", "invalid credentials"));
}
}

/**
* Set a new password for the authenticated caller and clear the must-change
* flag.
*/
@PostMapping("/change-password")
public ResponseEntity<Map<String, Object>> changePassword(
Authentication auth,
@Valid @RequestBody ChangePasswordRequest req) {
userService.changePassword(auth.getName(), req.newPassword());
return ResponseEntity.ok(Map.of("updated", true));
}

@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me(Authentication auth) {
var user = userRepository.findByUsername(auth.getName())
Expand All @@ -56,6 +81,7 @@ private Map<String, Object> toMap(UserEntity u) {
"username", u.getUsername(),
"email", u.getEmail() != null ? u.getEmail() : "",
"roles", u.getRoles(),
"enabled", u.isEnabled());
"enabled", u.isEnabled(),
"mustChangePassword", u.isMustChangePassword());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.syncflow.api.security;

import com.syncflow.api.config.JwtProperties;
import com.syncflow.api.user.repository.UserRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -26,16 +27,26 @@ public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtEncoder jwtEncoder;
private final JwtProperties jwtProperties;
private final UserRepository userRepository;

public AuthService(AuthenticationManager authenticationManager,
JwtEncoder jwtEncoder,
JwtProperties jwtProperties) {
JwtProperties jwtProperties,
UserRepository userRepository) {
this.authenticationManager = authenticationManager;
this.jwtEncoder = jwtEncoder;
this.jwtProperties = jwtProperties;
this.userRepository = userRepository;
}

public String login(String username, String password) {
/**
* Result of a successful login: the bearer token + whether the password must
* change.
*/
public record LoginResult(String token, boolean mustChangePassword) {
}

public LoginResult login(String username, String password) {
// authenticate() returns the populated principal (UserDetails) — carry the
// roles from it instead of re-querying the user store.
var auth = authenticationManager.authenticate(
Expand All @@ -45,7 +56,10 @@ public String login(String username, String password) {
.map(GrantedAuthority::getAuthority)
.map(a -> a.startsWith("ROLE_") ? a.substring("ROLE_".length()) : a)
.toList();
return issueToken(user.getUsername(), roles);
var mustChangePassword = userRepository.findByUsername(user.getUsername())
.map(u -> u.isMustChangePassword())
.orElse(false);
return new LoginResult(issueToken(user.getUsername(), roles), mustChangePassword);
}

private String issueToken(String username, java.util.List<String> roles) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public void require(ResourcePermission permission) {
}

public boolean isPermitted(ResourcePermission permission, TenantContext ctx) {
var policies = policyResolver.resolve(ctx.tenantId(), ctx.userId());
var policies = policyResolver.resolve(ctx.tenantId(), ctx.userId(), ctx.roles());
return policies.contains(permission);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,26 @@
import org.springframework.stereotype.Component;

import java.util.EnumSet;
import java.util.Set;

@Component
public class PolicyResolver {

/** The role that grants workspace-admin (full) permissions. */
public static final String ADMIN_ROLE = "ADMIN";

public EnumSet<ResourcePermission> resolve(TenantId tenantId, String userId) {
return resolve(tenantId, userId, Set.of());
}

public EnumSet<ResourcePermission> resolve(TenantId tenantId, String userId, Set<String> roles) {
EnumSet<ResourcePermission> permissions = EnumSet.of(ResourcePermission.METRICS_READ,
ResourcePermission.PIPELINE_READ, ResourcePermission.CONNECTION_READ);

permissions.addAll(ResourcePermission.developer());
if (userId != null && userId.equals("admin")) {
boolean isAdmin = (userId != null && userId.equals("admin"))
|| (roles != null && roles.contains(ADMIN_ROLE));
if (isAdmin) {
permissions.addAll(ResourcePermission.workspaceAdmin());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,23 @@ public UserEntity create(String username, String password, String email, String
u.setEmail(email);
u.setRoles(roles == null || roles.isBlank() ? RoleConstants.USER : roles);
u.setEnabled(true);
// Admin-provisioned accounts must set their own password on first login.
u.setMustChangePassword(true);
u.setCreatedAt(now);
u.setUpdatedAt(now);
return repository.save(u);
}

/** Set a new password and clear the must-change flag (first-login flow). */
public UserEntity changePassword(String username, String newPassword) {
var u = repository.findByUsername(username)
.orElseThrow(() -> new NoSuchElementException("User not found: " + username));
u.setPasswordHash(passwordEncoder.encode(newPassword));
u.setMustChangePassword(false);
u.setUpdatedAt(Instant.now());
return repository.save(u);
}

public UserEntity update(String id, String email, String roles, Boolean enabled) {
var u = find(id);
if (email != null)
Expand Down Expand Up @@ -83,7 +95,8 @@ public Map<String, Object> toMap(UserEntity u) {
"username", u.getUsername(),
"email", u.getEmail() != null ? u.getEmail() : "",
"roles", u.getRoles(),
"enabled", u.isEnabled());
"enabled", u.isEnabled(),
"mustChangePassword", u.isMustChangePassword());
}

/** Thrown when creating a user whose username already exists. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public class UserEntity {
@Column(nullable = false)
private boolean enabled;

/** Admin-provisioned accounts must set their own password on first login. */
@Column(name = "must_change_password", nullable = false)
private boolean mustChangePassword;

@Column(name = "created_at", nullable = false)
private Instant createdAt;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Force admin-provisioned accounts to set their own password on first login.
ALTER TABLE app_users
ADD COLUMN must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
Loading
Loading