diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d286b2..296e3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.secrets` resolves beside the definitions. The + CLI's one file reads as before. + ## [0.15.0] - 2026-10-08 ### Changed diff --git a/src/main/java/com/pointblue/dirxml/dev/deploy/Environments.java b/src/main/java/com/pointblue/dirxml/dev/deploy/Environments.java index 93c9750..ffac709 100644 --- a/src/main/java/com/pointblue/dirxml/dev/deploy/Environments.java +++ b/src/main/java/com/pointblue/dirxml/dev/deploy/Environments.java @@ -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} @@ -30,6 +32,12 @@ * stg.eventsTree=TREE # the tree name DNs in the store start with (default: from the vault) * stg.sshUser=root * + * A hosted server keeps two files (DirXMLDevWeb's docs/multi-user.md): the project's + * definitions (every key above except the secret ones) and one person's + * credentials (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 { @@ -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 .servers==;…}, 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) { @@ -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 defined; + /** The directory a relative {@code .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 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 .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 secretKeys(Path file) throws IOException { + List 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 .secrets} resolves beside the definitions file. + */ + public static Environments load(Path definitions, Path credentials) throws IOException { + List 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 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. */ @@ -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; @@ -185,7 +260,8 @@ public List 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; } @@ -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"); @@ -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; } diff --git a/src/main/java/com/pointblue/dirxml/dev/deploy/Servers.java b/src/main/java/com/pointblue/dirxml/dev/deploy/Servers.java index fca66f9..20cbcc0 100644 --- a/src/main/java/com/pointblue/dirxml/dev/deploy/Servers.java +++ b/src/main/java/com/pointblue/dirxml/dev/deploy/Servers.java @@ -57,12 +57,7 @@ private Servers() { /** {@code .servers==;=} — the tree's other servers, when it cannot describe them. */ public static Map urls(Environments.Environment env) { Map 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; } diff --git a/src/test/java/com/pointblue/dirxml/dev/deploy/EnvironmentsTwoFilesTest.java b/src/test/java/com/pointblue/dirxml/dev/deploy/EnvironmentsTwoFilesTest.java new file mode 100644 index 0000000..e9d547d --- /dev/null +++ b/src/test/java/com/pointblue/dirxml/dev/deploy/EnvironmentsTwoFilesTest.java @@ -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); + } +}