Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added

- **Two environment files** for a hosted server (DirXMLDevWeb's `docs/multi-user.md`):
`Environments.load(definitions, credentials)` merges the project's definitions (no secret keys;
the load refuses a file that holds one, `Environments.secretKeys` lists them) with one person's
credentials (their `bindDn`, `password…`, `trustAll`, `eventsPassword…`, or whole environments
of their own — `Described.own`). A relative `<env>.secrets` resolves beside the definitions. The
CLI's one file reads as before.

## [0.15.0] - 2026-10-08

### Changed
Expand Down
81 changes: 79 additions & 2 deletions src/main/java/com/pointblue/dirxml/dev/deploy/Environments.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;

/**
* Vault targets, from a local, gitignored {@code environments.properties}
Expand All @@ -30,6 +32,12 @@
* stg.eventsTree=TREE # the tree name DNs in the store start with (default: from the vault)
* stg.sshUser=root
* </pre>
* A hosted server keeps two files (DirXMLDevWeb's docs/multi-user.md): the project's
* <em>definitions</em> (every key above except the secret ones) and one person's
* <em>credentials</em> (their {@code bindDn}, {@code password…}, {@code trustAll},
* {@code eventsPassword…}, or whole environments of their own). {@link #load(Path, Path)} merges
* them, the person's keys over the definitions', and refuses a definitions file that holds a
* secret key.
*/
public final class Environments {

Expand All @@ -49,6 +57,8 @@ public static final class Environment {
public final String sshUser;
/** The Event Logger's store, or null when the environment has none (docs/event-store.md). */
public EventsConfig events;
/** {@code <env>.servers=<serverDn>=<url>;…}, the tree's other servers when it cannot describe them; null when unset. */
public String servers;

Environment(String name, String url, String bindDn, String password, String driverSetDn,
Tier tier, String requires, Path secretsFile, boolean trustAll, String sshHost, String sshUser) {
Expand Down Expand Up @@ -83,9 +93,66 @@ public String toString() {
private final Properties props;
private final Path file;

/** Names the definitions file holds, when two files were merged; null for one file. */
private final Set<String> defined;
/** The directory a relative {@code <env>.secrets} resolves against. */
private final Path secretsDir;

private Environments(Properties props, Path file) {
this(props, file, null, file == null ? null : file.toAbsolutePath().getParent());
}

private Environments(Properties props, Path file, Set<String> defined, Path secretsDir) {
this.props = props;
this.file = file;
this.defined = defined;
this.secretsDir = secretsDir;
}

/** A key that holds, or points at, a secret: {@code <env>.password}, {@code .eventsPassword}, {@code .appsPassword}, each with its {@code Env|Command|Keychain} form. */
private static final Pattern SECRET_KEY = Pattern.compile("[^.]+\\.[A-Za-z0-9_]*[pP]assword(Env|Command|Keychain)?");

/** The secret keys a file holds, sorted; empty when it holds none or does not exist. */
public static List<String> secretKeys(Path file) throws IOException {
List<String> out = new ArrayList<>();
if (file == null || !Files.isRegularFile(file)) {
return out;
}
for (String k : Secrets.parse(Files.readString(file, StandardCharsets.UTF_8)).stringPropertyNames()) {
if (SECRET_KEY.matcher(k).matches()) {
out.add(k);
}
}
out.sort(null);
return out;
}

/**
* The definitions file merged with one person's credentials file: a key in the credentials
* file replaces the definition's; a name only in the credentials file is that person's own.
* The definitions file must hold no secret key ({@link #secretKeys}); the credentials file
* may be absent. A relative {@code <env>.secrets} resolves beside the definitions file.
*/
public static Environments load(Path definitions, Path credentials) throws IOException {
List<String> secret = secretKeys(definitions);
if (!secret.isEmpty()) {
throw new IOException("the environment definitions " + definitions + " hold secret keys that belong in a person's credentials file: " + String.join(", ", secret));
}
Properties defs = Files.isRegularFile(definitions) ? Secrets.parse(Files.readString(definitions, StandardCharsets.UTF_8)) : new Properties();
Properties merged = new Properties();
merged.putAll(defs);
if (credentials != null && Files.isRegularFile(credentials)) {
SecretSource.warnIfShared(credentials);
merged.putAll(Secrets.parse(Files.readString(credentials, StandardCharsets.UTF_8)));
}
Set<String> defined = new TreeSet<>();
for (String k : defs.stringPropertyNames()) {
int dot = k.indexOf('.');
if (dot > 0) {
defined.add(k.substring(0, dot));
}
}
return new Environments(merged, credentials == null ? definitions : credentials, defined, definitions.toAbsolutePath().getParent());
}

/** Where the environments file is looked for, in order. */
Expand Down Expand Up @@ -144,9 +211,17 @@ public static final class Described {
public final boolean bindDnPresent;
public final boolean passwordConfigured;
public final boolean driverSetPresent;
/** Named only in the person's credentials file (two-file form); false for one file. */
public final boolean own;

Described(String name, String tier, boolean tierRecognized, boolean urlPresent,
boolean bindDnPresent, boolean passwordConfigured, boolean driverSetPresent) {
this(name, tier, tierRecognized, urlPresent, bindDnPresent, passwordConfigured, driverSetPresent, false);
}

Described(String name, String tier, boolean tierRecognized, boolean urlPresent,
boolean bindDnPresent, boolean passwordConfigured, boolean driverSetPresent, boolean own) {
this.own = own;
this.name = name;
this.tier = tier;
this.tierRecognized = tierRecognized;
Expand Down Expand Up @@ -185,7 +260,8 @@ public List<Described> describe() {
boolean recognized = tier.equals("dev") || tier.equals("stg") || tier.equals("prd");
out.add(new Described(name, tier, recognized,
present(name, "url"), present(name, "bindDn"),
SecretSource.has(props, name + ".password"), present(name, "driverSet")));
SecretSource.has(props, name + ".password"), present(name, "driverSet"),
defined != null && !defined.contains(name)));
}
return out;
}
Expand Down Expand Up @@ -214,7 +290,7 @@ public Environment get(String name) throws IOException {
String requires = props.getProperty(name + ".requires");
String secrets = props.getProperty(name + ".secrets");
Path secretsFile = secrets == null || secrets.isBlank() ? null
: (file == null ? Paths.get(secrets) : file.toAbsolutePath().getParent().resolve(secrets));
: (secretsDir == null ? Paths.get(secrets) : secretsDir.resolve(secrets));
boolean trustAll = "true".equals(props.getProperty(name + ".trustAll")); // opt in; TLS is verified otherwise
String sshHost = props.getProperty(name + ".sshHost");
String sshUser = props.getProperty(name + ".sshUser");
Expand All @@ -223,6 +299,7 @@ public Environment get(String name) throws IOException {
sshHost == null || sshHost.isBlank() ? null : sshHost.trim(),
sshUser == null || sshUser.isBlank() ? null : sshUser.trim());
env.events = eventsOf(name);
env.servers = property(name, "servers");
return env;
}

Expand Down
7 changes: 1 addition & 6 deletions src/main/java/com/pointblue/dirxml/dev/deploy/Servers.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,7 @@ private Servers() {
/** {@code <env>.servers=<serverDn>=<url>;<serverDn>=<url>} — the tree's other servers, when it cannot describe them. */
public static Map<String, String> urls(Environments.Environment env) {
Map<String, String> out = new LinkedHashMap<>();
String raw;
try {
raw = Environments.load().property(env.name, "servers");
} catch (Exception e) {
return out;
}
String raw = env.servers;
if (raw == null) {
return out;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.pointblue.dirxml.dev.deploy;

import org.junit.Test;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/** The hosted server's two files: the project's definitions and one person's credentials. */
public class EnvironmentsTwoFilesTest {

private static Path write(Path dir, String name, String text) throws IOException {
Path f = dir.resolve(name);
Files.writeString(f, text, StandardCharsets.UTF_8);
return f;
}

@Test
public void credentialsOverlayTheDefinitionsAndOwnNamesAreTheirs() throws Exception {
Path dir = Files.createTempDirectory("envs");
Path defs = write(dir.resolve(Files.createDirectories(dir.resolve("environments")).getFileName()), "definitions.properties",
"stg.url=ldaps://stg:636\nstg.driverSet=cn=driverset1,o=system\nstg.tier=stg\nstg.secrets=secrets-stg.properties\nstg.trustAll=true\n");
Path creds = write(Files.createDirectories(dir.resolve("users").resolve("alice")), "environments.properties",
"stg.bindDn=cn=alice,o=system\nstg.password=pw\nstg.trustAll=false\nmine.url=ldaps://mine:636\nmine.bindDn=cn=a,o=x\nmine.password=p\nmine.driverSet=cn=ds,o=x\n");
Environments e = Environments.load(defs, creds);
assertEquals(List.of("mine", "stg"), e.names());
Environments.Environment stg = e.get("stg");
assertEquals("ldaps://stg:636", stg.url);
assertEquals("cn=alice,o=system", stg.bindDn);
assertEquals("pw", stg.password);
assertFalse("the person's key wins", stg.trustAll);
assertEquals("a relative secrets file sits beside the definitions", defs.getParent().resolve("secrets-stg.properties").toAbsolutePath(), stg.secretsFile);
Environments.Described dStg = e.describe().stream().filter(d -> d.name.equals("stg")).findFirst().orElseThrow();
Environments.Described dMine = e.describe().stream().filter(d -> d.name.equals("mine")).findFirst().orElseThrow();
assertFalse(dStg.own);
assertTrue(dMine.own);
assertTrue(dStg.passwordConfigured && dStg.bindDnPresent);
}

@Test
public void aDefinitionWithoutACredentialIsListedButNotConnected() throws Exception {
Path dir = Files.createTempDirectory("envs");
Path defs = write(dir, "definitions.properties", "prd.url=ldaps://prd:636\nprd.driverSet=cn=ds,o=system\nprd.tier=prd\n");
Environments e = Environments.load(defs, dir.resolve("absent.properties"));
Environments.Described d = e.describe().get(0);
assertTrue(d.urlPresent && d.driverSetPresent);
assertFalse(d.bindDnPresent || d.passwordConfigured);
assertFalse(d.own);
try {
e.get("prd");
fail("connected without a credential");
} catch (IOException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("bindDn"));
}
}

@Test
public void definitionsHoldingASecretKeyAreRefused() throws Exception {
Path dir = Files.createTempDirectory("envs");
Path defs = write(dir, "definitions.properties", "stg.url=ldaps://stg:636\nstg.passwordKeychain=idm/stg\nstg.eventsPasswordEnv=X\nstg.appsPassword=y\nstg.eventsUser=reader\n");
assertEquals(List.of("stg.appsPassword", "stg.eventsPasswordEnv", "stg.passwordKeychain"), Environments.secretKeys(defs));
try {
Environments.load(defs, null);
fail("accepted secret keys in the definitions");
} catch (IOException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("stg.passwordKeychain"));
}
assertTrue(Environments.secretKeys(dir.resolve("none.properties")).isEmpty());
}

@Test
public void oneFileStillReadsAsBefore() throws Exception {
Path dir = Files.createTempDirectory("envs");
Path f = write(dir, "environments.properties", "dev.url=ldaps://dev:636\ndev.bindDn=cn=admin,o=system\ndev.password=pw\ndev.driverSet=cn=ds,o=system\n");
Environments e = Environments.load(f);
assertFalse(e.describe().get(0).own);
assertEquals("pw", e.get("dev").password);
}
}
Loading