From e42fe903790eece2ea897702d6c62d46077c6e47 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Fri, 30 Oct 2026 13:19:16 -0500 Subject: [PATCH 1/3] Single sign-on: refuse accounts locked by intruder detection eDirectory refuses the proxied-authorization control for disabled and expired accounts, but accepts it for an account locked by intruder detection: on the lab (9.3.3) the locked account's own bind returned 53 while a proxied read as it succeeded. So a locked person could sign in through single sign-on and act in the console. OidcLogin's search now reads lockedByIntruder and loginIntruderResetTime and refuses with the new code "locked". A lock whose reset time has passed counts as over: eDirectory leaves the attribute TRUE after the reset until the next login (checked on the lab with a real lockout). A single sign-on session reaching another server (Server.on) checks the same on that server before acting as the person there. The service account check covers reading the two attributes, and the live check LivePhase3Check covers a locked and a lapsed account. Co-Authored-By: Claude Opus 5.5 --- .../console/server/auth/OidcLogin.java | 40 ++++++++++++++++- .../pointblue/console/server/http/Server.java | 19 +++++++- .../server/http/ServiceAccountCheck.java | 6 ++- .../console/server/auth/OidcLoginTest.java | 38 ++++++++++++++++ .../console/server/http/Phase3Test.java | 45 ++++++++++++++++++- .../console/server/live/LivePhase3Check.java | 20 ++++++++- console-web/src/pages/SignIn.tsx | 1 + docs/DEPLOYING.md | 5 ++- 8 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 console-server/src/test/java/com/pointblue/console/server/auth/OidcLoginTest.java diff --git a/console-server/src/main/java/com/pointblue/console/server/auth/OidcLogin.java b/console-server/src/main/java/com/pointblue/console/server/auth/OidcLogin.java index cedd7a2..36ea38a 100644 --- a/console-server/src/main/java/com/pointblue/console/server/auth/OidcLogin.java +++ b/console-server/src/main/java/com/pointblue/console/server/auth/OidcLogin.java @@ -7,8 +7,10 @@ import java.io.IOException; import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -26,13 +28,16 @@ * own rights. Before a session is made, the person's own entry is read under the control: eDirectory * refuses the control for disabled and expired accounts, and when the service account lacks * Supervisor over the person, so a sign-in that could not act is refused here rather than later. + * eDirectory does accept the control for an account locked by intruder detection (checked on 9.3.3: + * the account's own bind fails, a proxied read as it succeeds), so the console checks that itself, + * from the service account's search: see {@link #locked}. * *

The service account itself, the break-glass accounts and the directory's {@code neverProxy} list * are never acted as; they sign in directly. Console grants come from the provider's groups. */ public final class OidcLogin { - /** {@code code}: {@code not-configured}, {@code provider}, {@code claim-missing}, {@code no-account}, {@code ambiguous-account}, {@code never-proxy}, {@code no-role}, {@code proxy-refused}, {@code unavailable}. */ + /** {@code code}: {@code not-configured}, {@code provider}, {@code claim-missing}, {@code no-account}, {@code ambiguous-account}, {@code never-proxy}, {@code locked}, {@code no-role}, {@code proxy-refused}, {@code unavailable}. */ public record Refused(String code, String message) { } @@ -41,6 +46,8 @@ public record SignedIn(Sessions.Principal principal, Ldap connection, String nex private static final Duration TIMEOUT = Duration.ofSeconds(10); private static final DateTimeFormatter GENERALIZED = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").withZone(ZoneOffset.UTC); + /** What {@link #locked} reads. */ + public static final String[] LOCK_ATTRIBUTES = {"lockedByIntruder", "loginIntruderResetTime"}; private record Client(DirectoryConfig.Oidc settings, String secret, OidcClient client) { } @@ -147,6 +154,9 @@ public Object finish(DirectoryConfig d, String secret, String serviceAccountPass if (neverProxy(d).stream().anyMatch(n -> DirectLogin.sameDn(n, dn))) { return new Refused("never-proxy", "This account signs in directly, not through single sign-on."); } + if (locked(found.get(0))) { + return new Refused("locked", "Your account is locked by intruder detection. Ask an administrator or the help desk to unlock it, or wait for the lock to reset."); + } if (grants.isEmpty()) { return new Refused("no-role", "Your account has no Directory Console role. Ask an administrator to map one of your groups to a role."); } @@ -194,7 +204,7 @@ List find(Ldap svc, DirectoryConfig d, DirectoryConfig.Oidc o, Strin List bases = !o.searchBases().isEmpty() ? o.searchBases() : d.searchBases().isEmpty() ? List.of("") : d.searchBases(); List out = new ArrayList<>(); for (String base : bases) { - for (Ldap.Entry e : svc.search(base, f.toString(), args.toArray(), SearchControls.SUBTREE_SCOPE, new String[] {"1.1"}, 2)) { + for (Ldap.Entry e : svc.search(base, f.toString(), args.toArray(), SearchControls.SUBTREE_SCOPE, LOCK_ATTRIBUTES, 2)) { if (out.stream().noneMatch(x -> DirectLogin.sameDn(x.dn(), e.dn()))) { out.add(e); } @@ -203,6 +213,32 @@ List find(Ldap svc, DirectoryConfig d, DirectoryConfig.Oidc o, Strin return out; } + /** Whether {@code person} (read with {@link #LOCK_ATTRIBUTES}) is locked by intruder detection now. */ + public boolean locked(Ldap.Entry person) { + return locked(person, clock.instant()); + } + + /** + * {@code lockedByIntruder} TRUE, unless its {@code loginIntruderResetTime} has passed: eDirectory + * releases the lock then but leaves the attribute TRUE until the next login (checked on 9.3.3). A reset time that + * cannot be read counts as still locked. The service account can read both, since acting as the + * person needs Supervisor over them. + */ + static boolean locked(Ldap.Entry person, Instant now) { + if (!"TRUE".equalsIgnoreCase(person.first("lockedByIntruder"))) { + return false; + } + String reset = person.first("loginIntruderResetTime"); + if (reset == null) { + return true; + } + try { + return Instant.from(GENERALIZED.parse(reset.trim())).isAfter(now); + } catch (DateTimeParseException e) { + return true; + } + } + /** The DNs a claim value maps to (none, one, or the first two of several), for testing a mapping. */ public List mapped(Ldap svc, DirectoryConfig d, String value) throws NamingException { return find(svc, d, d.oidc(), value).stream().map(Ldap.Entry::dn).toList(); diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Server.java b/console-server/src/main/java/com/pointblue/console/server/http/Server.java index ae54152..59a6054 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Server.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Server.java @@ -605,7 +605,9 @@ private Map serverAddresses(String directoryId) { /** * The connection for an operation on server {@code guid}'s replica (null or the session's own * server: the session's connection). A single sign-on session reaches another server the way it - * reaches its own: the service account with the proxied-authorization control. A direct sign-in + * reaches its own: the service account with the proxied-authorization control, once it has checked + * on that server that the person is not locked by intruder detection (eDirectory accepts the control + * for a locked account; see {@link com.pointblue.console.server.auth.OidcLogin#locked}). A direct sign-in * has no password to reuse, so the first use of a server answers {@code server-sign-in-required} * and the person signs in to it once ({@code POST /session/servers}). */ @@ -650,6 +652,21 @@ On on(Sessions.Session s, String guid) throws javax.naming.NamingException { } catch (com.pointblue.console.server.ldap.Ldap.BadCredentials e) { throw new Http.Problem(503, "unavailable", "the console's service account could not sign in to " + where.name()); } + com.pointblue.console.server.ldap.Ldap.Entry person; + try { + person = l.read(s.principal.dn(), com.pointblue.console.server.auth.OidcLogin.LOCK_ATTRIBUTES); + } catch (javax.naming.NamingException e) { + l.close(); + throw new Http.Problem(503, "unavailable", "your account could not be read on " + where.name() + ": " + e.getMessage()); + } + if (person == null) { + l.close(); + throw new Http.Problem(403, "no-account", "your account was not found on " + where.name()); + } + if (oidc.locked(person)) { + l.close(); + throw new Http.Problem(403, "locked", "your account is locked by intruder detection on " + where.name()); + } l.actAs(s.principal.dn()); SsoEngine e = ssoEngine(d, l, new com.pointblue.console.server.ldap.Ldap.Target(d.id(), where.host(), where.port()), s.principal.dn(), where.name()); Sessions.ServerConnection made = new Sessions.ServerConnection(where.dn(), where.name(), l, e.engine(), e.problem()); diff --git a/console-server/src/main/java/com/pointblue/console/server/http/ServiceAccountCheck.java b/console-server/src/main/java/com/pointblue/console/server/http/ServiceAccountCheck.java index dd06f00..da93199 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/ServiceAccountCheck.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/ServiceAccountCheck.java @@ -80,7 +80,9 @@ private void singleSignOn() { DirectoryConfig.Oidc o = d.oidc(); List bases = o.searchBases().isEmpty() ? d.searchBases() : o.searchBases(); for (String base : bases(bases)) { - people("single sign-on", base, o.objectClasses(), List.of(o.attribute()), true); + List attrs = new ArrayList<>(List.of(o.attribute())); + attrs.addAll(List.of(com.pointblue.console.server.auth.OidcLogin.LOCK_ATTRIBUTES)); // a locked person is refused + people("single sign-on", base, o.objectClasses(), attrs, true); } } @@ -146,7 +148,7 @@ private void people(String purpose, String base, List classes, List> a = new LinkedHashMap<>(); + if (locked != null) { + a.put("lockedbyintruder", List.of(locked)); + } + if (reset != null) { + a.put("loginintruderresettime", List.of(reset)); + } + return new Ldap.Entry("cn=pat,ou=users,o=acme", a); + } + + @Test + public void intruderLockout() { + assertFalse("never locked", OidcLogin.locked(person(null, null), NOW)); + assertFalse("unlocked", OidcLogin.locked(person("FALSE", "20261030130000Z"), NOW)); + assertTrue("locked, no reset time", OidcLogin.locked(person("TRUE", null), NOW)); + assertTrue("locked until later", OidcLogin.locked(person("TRUE", "20261030120001Z"), NOW)); + assertFalse("the reset time has passed", OidcLogin.locked(person("TRUE", "20261030115959Z"), NOW)); + assertFalse("at the reset time", OidcLogin.locked(person("TRUE", "20261030120000Z"), NOW)); + assertTrue("an unreadable reset time counts as locked", OidcLogin.locked(person("TRUE", "soon"), NOW)); + } +} diff --git a/console-server/src/test/java/com/pointblue/console/server/http/Phase3Test.java b/console-server/src/test/java/com/pointblue/console/server/http/Phase3Test.java index f1f3000..5d1d383 100644 --- a/console-server/src/test/java/com/pointblue/console/server/http/Phase3Test.java +++ b/console-server/src/test/java/com/pointblue/console/server/http/Phase3Test.java @@ -58,7 +58,11 @@ public void start() throws Exception { "dn: cn=Sales Team,ou=Sales,o=acme\nobjectClass: groupOfNames\ncn: Sales Team\nmember: cn=ed,ou=Sales,o=acme", "dn: cn=ed,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: ed\nsn: Editor\nuserPassword: ed-pw\ngroupMembership: cn=Editors,ou=Groups,o=acme", "dn: cn=help,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: help\nsn: Desk\nuserPassword: help-pw\ngroupMembership: cn=Help Desk,ou=Groups,o=acme", - "dn: cn=alice,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: alice\nsn: Ali\nuid: alice\ntitle: Rep\nlockedByIntruder: TRUE\nloginIntruderAttempts: 5", + "dn: cn=alice,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: alice\nsn: Ali\nuid: alice\ntitle: Rep", + "dn: cn=lou,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: lou\nsn: Lou\nuid: lou\nlockedByIntruder: TRUE\nloginIntruderAttempts: 5\nloginIntruderResetTime: 20991231000000Z", + "dn: cn=lapsed,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: lapsed\nsn: Lapsed\nuid: lapsed\nlockedByIntruder: TRUE\nloginIntruderAttempts: 5\nloginIntruderResetTime: 20200101000000Z", + "dn: ou=servers,o=system\nobjectClass: organizationalUnit\nou: servers", + "dn: cn=edir2,ou=servers,o=system\nobjectClass: ncpServer\ncn: edir2\nstatus: 2", "dn: cn=bob,ou=Engineering,o=acme\nobjectClass: inetOrgPerson\ncn: bob\nsn: Bob\nuid: bob", "dn: cn=gone,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: gone\nsn: Gone\nuid: gone\nloginDisabled: TRUE"); idp = new FakeIdp() @@ -66,7 +70,9 @@ public void start() throws Exception { .person("bob", Map.of("preferred_username", "bob", "groups", List.of("nobody"))) .person("root", Map.of("preferred_username", "admin", "groups", List.of("sales-editors"))) .person("ghost", Map.of("preferred_username", "nobody-here", "groups", List.of("sales-editors"))) - .person("gone", Map.of("preferred_username", "gone", "groups", List.of("sales-editors"))); + .person("gone", Map.of("preferred_username", "gone", "groups", List.of("sales-editors"))) + .person("lou", Map.of("preferred_username", "lou", "groups", List.of("sales-editors"))) + .person("lapsed", Map.of("preferred_username", "lapsed", "groups", List.of("sales-editors"))); dataPath = tmp.newFolder("data").toPath(); server = new Server(new DataDir(dataPath), ServerClient.loopback(), "https://localhost:8443", new PrintStream(new ByteArrayOutputStream())); server.start(); @@ -232,6 +238,7 @@ public void aHelpDeskRunsOnlyItsPresetsWithinItsScope() throws Exception { assertEquals(2, presets.size()); String alice = guid(h, "cn=alice,ou=Sales,o=acme"); String bob = guid(h, "cn=bob,ou=Engineering,o=acme"); + lock("cn=alice,ou=Sales,o=acme", null); assertEquals(List.of("hr", "reset"), Json.asMap(call("GET", "/api/v2/trees/acme/objects/" + alice, null, h).body().get("allowed")).get("presets")); Reply ran = call("POST", "/api/v2/trees/acme/objects/" + alice + "/presets/reset", Map.of("password", "N3w-secret!", "unlock", true), h); @@ -264,6 +271,16 @@ public void aHelpDeskRunsOnlyItsPresetsWithinItsScope() throws Exception { // ---- OpenID Connect ------------------------------------------------------------------------- + /** Lock {@code dn} by intruder detection, until {@code reset} (generalized time; null for no reset time). */ + private void lock(String dn, String reset) throws Exception { + List mods = new java.util.ArrayList<>(List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "lockedByIntruder", "TRUE"), + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "loginIntruderAttempts", "5"))); + mods.add(reset == null ? new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "loginIntruderResetTime") + : new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "loginIntruderResetTime", reset)); + edir.server.modify(dn, mods); + } + /** The browser's part: start, follow the provider back, and present the callback with the state cookie. */ private Reply sso(String person, String next) throws Exception { idp.signInAs = person; @@ -343,6 +360,12 @@ public void singleSignOnRefusals() throws Exception { assertEquals("the tree admin is never acted as", "/?sso-error=never-proxy", sso("root", "/").raw().headers().firstValue("Location").orElseThrow()); assertEquals("/?sso-error=no-account", sso("ghost", "/").raw().headers().firstValue("Location").orElseThrow()); assertEquals("a disabled account", "/?sso-error=no-account", sso("gone", "/").raw().headers().firstValue("Location").orElseThrow()); + assertEquals("eDirectory accepts the control for a locked account; the console does not", + "/?sso-error=locked", sso("lou", "/").raw().headers().firstValue("Location").orElseThrow()); + assertTrue(audit().contains("\"code\":\"locked\"")); + Reply lapsed = sso("lapsed", "/"); + assertEquals("a lock whose reset time has passed is over", "/", lapsed.raw().headers().firstValue("Location").orElseThrow()); + assertEquals("cn=lapsed,ou=Sales,o=acme", call("GET", "/api/v2/session", null, headers(lapsed)).body().get("dn")); assertEquals("an outside next becomes /", "/", sso("alice", "//evil.example/").raw().headers().firstValue("Location").orElseThrow()); // a callback without the browser's state cookie (started elsewhere) is refused @@ -355,6 +378,24 @@ public void singleSignOnRefusals() throws Exception { assertTrue(cb.raw().headers().allValues("Set-Cookie").stream().noneMatch(c -> c.startsWith(Server.COOKIE + "="))); } + @Test + public void singleSignOnReachesAnotherServerOnlyWhileUnlocked() throws Exception { + String edir2 = guid(withCsrf(signIn("cn=admin,o=system", "admin-pw")), "cn=edir2,ou=servers,o=system"); + server.config.update(server.config.version(), "test", "address", doc -> Json.asMap(Json.asMap(doc.get("directories")).get("acme")) + .put("serverAddresses", Map.of("cn=edir2,ou=servers,o=system", "127.0.0.1:" + edir.port()))); + Map h = withCsrf(headers(sso("alice", "/"))); + String self = guid(h, "cn=alice,ou=Sales,o=acme"); + + lock("cn=alice,ou=Sales,o=acme", "20991231000000Z"); + Reply locked = call("GET", "/api/v2/trees/acme/objects/" + self + "?server=" + edir2, null, h); + assertEquals(String.valueOf(locked.body()), 403, locked.status()); + assertEquals("locked", locked.body().get("title")); + + lock("cn=alice,ou=Sales,o=acme", "20200101000000Z"); + Reply on2 = call("GET", "/api/v2/trees/acme/objects/" + self + "?server=" + edir2, null, h); + assertEquals("a lock whose reset time has passed is over: " + on2.body(), 200, on2.status()); + } + // ---- settings ------------------------------------------------------------------------------- @Test diff --git a/console-server/src/test/java/com/pointblue/console/server/live/LivePhase3Check.java b/console-server/src/test/java/com/pointblue/console/server/live/LivePhase3Check.java index b4e9fdd..9fbc2be 100644 --- a/console-server/src/test/java/com/pointblue/console/server/live/LivePhase3Check.java +++ b/console-server/src/test/java/com/pointblue/console/server/live/LivePhase3Check.java @@ -31,7 +31,8 @@ * service account, a fake identity provider on loopback vouches for a test user made for the run, and * the console must act with that user's own eDirectory rights (RFC 4370). In a new container under * {@code parent}: a user with a uid, then sign-in through the provider, reads, a write the admin can - * make and the user cannot, Identity Manager under the user's rights, and a disabled account. No password is set or used for the test user; no + * make and the user cannot, Identity Manager under the user's rights, an account locked by intruder + * detection (and one whose lock's reset time has passed), and a disabled account. No password is set or used for the test user; no * rights or IRFs are written. The container is deleted at the end. Not a JUnit test. * * java -cp … LivePhase3Check @@ -122,6 +123,23 @@ public static void main(String[] args) throws Exception { asUserDrv.status() + " state=" + asUserDrv.body().get("state") + detail(asUserDrv)); } + // eDirectory accepts the control for an account locked by intruder detection; the console refuses it + String future = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).plusHours(1)); + String past = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).minusHours(1)); + LiveSetupCheck.Reply lock = call("PATCH", "/api/v2/trees/" + TREE + "/objects/" + userGuid, Map.of("changes", List.of( + Map.of("op", "replace", "attribute", "lockedByIntruder", "values", List.of("TRUE")), + Map.of("op", "replace", "attribute", "loginIntruderResetTime", "values", List.of(future)))), withEtag(admin, userGuid)); + ok("lock the test user until " + future, lock.status() == 200, lock.status() + detail(lock)); + String locked = sso(idp, "tester").raw().headers().firstValue("Location").orElse(""); + ok("a locked account cannot sign in", locked.equals("/?sso-error=locked"), locked); + LiveSetupCheck.Reply lapse = call("PATCH", "/api/v2/trees/" + TREE + "/objects/" + userGuid, Map.of("changes", List.of( + Map.of("op", "replace", "attribute", "loginIntruderResetTime", "values", List.of(past)))), withEtag(admin, userGuid)); + ok("move the lock's reset time to " + past, lapse.status() == 200, lapse.status() + detail(lapse)); + String lapsed = sso(idp, "tester").raw().headers().firstValue("Location").orElse(""); + ok("a lock whose reset time has passed is over", lapsed.equals("/"), lapsed); + LiveSetupCheck.Reply unlock = call("POST", "/api/v2/trees/" + TREE + "/objects/" + userGuid + "/unlock", Map.of(), admin); + ok("unlock the test user", unlock.status() == 200, unlock.status() + detail(unlock)); + LiveSetupCheck.Reply account = call("GET", "/api/v2/trees/" + TREE + "/objects/" + userGuid + "/account", null, admin); Map h = new HashMap<>(admin); h.put("If-Match", account.raw().headers().firstValue("ETag").orElse("*")); diff --git a/console-web/src/pages/SignIn.tsx b/console-web/src/pages/SignIn.tsx index fef0f52..d10fa2e 100644 --- a/console-web/src/pages/SignIn.tsx +++ b/console-web/src/pages/SignIn.tsx @@ -54,6 +54,7 @@ const SSO_ERRORS: Record = { 'no-account': 'No active account in this directory matches your single sign-on identity.', 'ambiguous-account': 'More than one account matches your single sign-on identity. Ask an administrator to fix the mapping.', 'never-proxy': 'This account signs in with its name and password, not through single sign-on.', + locked: 'Your account is locked by intruder detection. Ask an administrator or the help desk to unlock it, or wait for the lock to reset.', 'no-role': 'Your account has no Directory Console role. Ask an administrator to map one of your groups to a role.', 'proxy-refused': 'The directory would not let the console act for you: your account may be disabled or expired.', 'not-configured': 'Single sign-on is not set up for this directory.', diff --git a/docs/DEPLOYING.md b/docs/DEPLOYING.md index 8b9f040..4e40af4 100644 --- a/docs/DEPLOYING.md +++ b/docs/DEPLOYING.md @@ -143,8 +143,9 @@ People sign in with their eDirectory name and password, and act with their own e The provider (Entra ID, Okta, Keycloak, OpenText Access Manager or any OpenID Connect provider) says who the person is. The console finds their eDirectory account through the service account, and then acts as them with eDirectory's -proxied authorization, so they still have only their own rights, in Identity Manager too. Password sign-in stays available alongside it, -unless you limit it to the break-glass and listed accounts (step 7 below). +proxied authorization, so they still have only their own rights, in Identity Manager too. Accounts that are +disabled, expired or locked by intruder detection cannot sign in this way. +Password sign-in stays available alongside it, unless you limit it to the break-glass and listed accounts (step 7 below). **At the provider,** register an application (a "web" or "confidential" client): From fe0a0bd6ac135429e56cb3724ccede155e039cce Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Fri, 30 Oct 2026 17:08:22 -0500 Subject: [PATCH 2/3] Agents over MCP, phase 1: agent keys, /mcp and the read tools Agent keys (docs/MCP.md): a person signed in directly or with single sign-on makes a key under their name > Agent keys (password typed again, or a sign-in within 10 minutes). dc_ + 32 random bytes, SHA-256 kept in agents/keys.json on the data volume, outside the versioned configuration and outside backups. Viewer always, editor and operator only if the person has them, never admin, no task presets. 30 days by default, at most the directory's longest life (90) and 14 days with single sign-on; 14 days unused stops a key. Admins list and revoke everyone's. A key's calls run as the person the way single sign-on does: the service account's connection with the proxied-authorization control, and the engine opened acting as them. On every call the console reads the person's account as the service account and refuses a locked (intruder detection: eDirectory accepts the control for it), disabled or expired one; direct sign-in grants come from that read, single sign-on grants from the last sign-in. /mcp: JSON-RPC 2.0, Streamable HTTP, stateless. 22 read tools, each an in-process call to the existing API with the key's session (Mcp.Local), so roles, scopes and rights stay the API's. A key works only there. Per directory (Settings > Directory > Agent keys): on/off (off by default), longest life, and approveChanges, required by default, for the change tools of phase 2. Audit: agent-key.create/revoke/use, and the actor's agent (label and id) on entries made with a key. Co-Authored-By: Claude Opus 5.5 --- .../console/server/audit/AuditLog.java | 14 +- .../console/server/auth/AgentKeys.java | 332 +++++++++++ .../console/server/auth/DirectLogin.java | 20 +- .../console/server/auth/Sessions.java | 28 +- .../console/server/data/DataDir.java | 7 +- .../pointblue/console/server/http/Agents.java | 464 +++++++++++++++ .../pointblue/console/server/http/Idm.java | 10 +- .../pointblue/console/server/http/Mcp.java | 531 ++++++++++++++++++ .../console/server/http/Objects.java | 6 +- .../pointblue/console/server/http/Server.java | 69 ++- .../console/server/http/Settings.java | 22 +- .../pointblue/console/server/http/Trees.java | 3 +- .../console/server/auth/AgentKeysTest.java | 109 ++++ .../console/server/http/AgentsTest.java | 341 +++++++++++ .../console/server/ldap/FakeEdir.java | 10 + console-web/src/api.ts | 40 ++ console-web/src/components/Shell.tsx | 7 + console-web/src/help/content.ts | 33 ++ console-web/src/help/topics.test.ts | 2 +- console-web/src/help/topics.ts | 3 + console-web/src/pages/AgentKeysPage.tsx | 209 +++++++ console-web/src/pages/SettingsPage.tsx | 27 + docs/API.md | 15 + docs/MCP.md | 61 +- docs/STATUS.md | 2 + 25 files changed, 2322 insertions(+), 43 deletions(-) create mode 100644 console-server/src/main/java/com/pointblue/console/server/auth/AgentKeys.java create mode 100644 console-server/src/main/java/com/pointblue/console/server/http/Agents.java create mode 100644 console-server/src/main/java/com/pointblue/console/server/http/Mcp.java create mode 100644 console-server/src/test/java/com/pointblue/console/server/auth/AgentKeysTest.java create mode 100644 console-server/src/test/java/com/pointblue/console/server/http/AgentsTest.java create mode 100644 console-web/src/pages/AgentKeysPage.tsx diff --git a/console-server/src/main/java/com/pointblue/console/server/audit/AuditLog.java b/console-server/src/main/java/com/pointblue/console/server/audit/AuditLog.java index 70338bc..12f3e6f 100644 --- a/console-server/src/main/java/com/pointblue/console/server/audit/AuditLog.java +++ b/console-server/src/main/java/com/pointblue/console/server/audit/AuditLog.java @@ -55,10 +55,17 @@ public AuditLog(DataDir data) throws IOException { } } - /** Who acted: the IdP subject or the eDirectory DN, whichever applies. */ - public record Actor(String subject, String dn, String via) { + /** + * Who acted: the IdP subject or the eDirectory DN, whichever applies. {@code agent} names the agent key a call + * came with (its label and id), null for a person in a browser. + */ + public record Actor(String subject, String dn, String via, String agent) { public static final Actor SYSTEM = new Actor("system", null, "system"); + public Actor(String subject, String dn, String via) { + this(subject, dn, via, null); + } + Map json() { Map m = new LinkedHashMap<>(); m.put("subject", subject); @@ -66,6 +73,9 @@ Map json() { m.put("dn", dn); } m.put("via", via); + if (agent != null) { + m.put("agent", agent); + } return m; } } diff --git a/console-server/src/main/java/com/pointblue/console/server/auth/AgentKeys.java b/console-server/src/main/java/com/pointblue/console/server/auth/AgentKeys.java new file mode 100644 index 0000000..40cb54f --- /dev/null +++ b/console-server/src/main/java/com/pointblue/console/server/auth/AgentKeys.java @@ -0,0 +1,332 @@ +package com.pointblue.console.server.auth; + +import com.pointblue.console.server.data.DataDir; +import com.pointblue.dirxml.dev.json.Json; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.EnumSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Agent keys (docs/MCP.md): a person makes one for their AI agent, which then calls the console's MCP server as them. + * A key is {@code dc_} and 32 random bytes; only its SHA-256 is kept, with its label, directory, the person, the + * roles it may use (never admin) and its times. Keys live in {@code agents/keys.json} on the data volume, outside the + * versioned configuration and outside backups, so restoring an earlier configuration or a backup never brings back a + * revoked key. + * + *

A key stops working when it expires (30 days by default, at most the directory's longest life, and at most 14 + * days for a person who signed in with single sign-on), and after 14 days unused. Keys expired for a month are + * dropped from the file. + */ +public final class AgentKeys { + + public static final String PREFIX = "dc_"; + public static final Duration IDLE = Duration.ofDays(14); + public static final int DEFAULT_DAYS = 30; + public static final int MAX_DAYS = 90; + /** A single sign-on person's grants come from their last sign-in, so their keys live shorter. */ + public static final int SSO_MAX_DAYS = 14; + /** The most keys one person may have in one directory. */ + public static final int PER_PERSON = 20; + /** The roles a key may carry: never admin. */ + public static final Set CEILING = Set.copyOf(EnumSet.of(Role.VIEWER, Role.EDITOR, Role.OPERATOR)); + private static final Duration KEEP_EXPIRED = Duration.ofDays(30); + /** Last use is written at most this often (it only decides the 14-day idle limit). */ + private static final Duration TOUCH = Duration.ofMinutes(10); + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * One key. {@code via} is how the person signed in when they made it ({@code direct} or {@code oidc}); + * {@code grants} are a single sign-on person's grants as of their last sign-in (empty for direct sign-in, whose + * grants are read from the directory on every call). + */ + public record Key(String id, String hash, String label, String directoryId, String dn, String subject, String name, String via, + Set roles, List grants, Instant created, Instant expires, Instant lastUsed) { + + public Key { + roles = Set.copyOf(roles); + grants = List.copyOf(grants); + } + + public boolean expired(Instant now) { + return !expires.isAfter(now); + } + + public boolean idle(Instant now) { + return !(lastUsed == null ? created : lastUsed).plus(IDLE).isAfter(now); + } + + /** {@code active}, {@code expired} or {@code idle}. */ + public String state(Instant now) { + return expired(now) ? "expired" : idle(now) ? "idle" : "active"; + } + + /** How the audit names it: the label and the id. */ + public String ref() { + return label + " (" + id + ")"; + } + + /** For the key's owner and administrators: never the hash. */ + public Map json(Instant now) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + m.put("label", label); + m.put("directory", directoryId); + m.put("dn", dn); + m.put("name", name); + m.put("via", via); + m.put("roles", roles.stream().map(Role::id).sorted().toList()); + m.put("created", created.toString()); + m.put("expires", expires.toString()); + m.put("lastUsed", lastUsed == null ? null : lastUsed.toString()); + m.put("state", state(now)); + return m; + } + + Key used(Instant at) { + return new Key(id, hash, label, directoryId, dn, subject, name, via, roles, grants, created, expires, at); + } + + Key withGrants(List g) { + return new Key(id, hash, label, directoryId, dn, subject, name, via, roles, g, created, expires, lastUsed); + } + } + + /** A new key: the token, shown to the person once, and what is kept. */ + public record Made(String token, Key key) { + } + + private final Path file; + private final Clock clock; + /** By hash. */ + private final Map keys = new LinkedHashMap<>(); + + public AgentKeys(DataDir data) throws IOException { + this(data.agents().resolve("keys.json"), Clock.systemUTC()); + } + + AgentKeys(Path file, Clock clock) throws IOException { + this.file = file; + this.clock = clock; + if (Files.exists(file)) { + Map doc = Json.asMap(Json.parse(Files.readString(file, StandardCharsets.UTF_8))); + for (Object o : Json.asList(doc.get("keys"))) { + Key k = parse(Json.asMap(o)); + keys.put(k.hash(), k); + } + } + Instant now = clock.instant(); + if (keys.values().removeIf(k -> k.expires().plus(KEEP_EXPIRED).isBefore(now))) { + save(); + } + } + + public Instant now() { + return clock.instant(); + } + + /** Make a key for {@code p} in its directory, using at most {@code roles}, for {@code life}. */ + public synchronized Made create(String label, Sessions.Principal p, Set roles, Duration life) throws IOException { + byte[] b = new byte[32]; + RANDOM.nextBytes(b); + String token = PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(b); + String hash = sha256(token); + Instant now = clock.instant(); + List grants = "oidc".equals(p.via()) ? withoutPresets(p.grants()) : List.of(); + Key k = new Key(hash.substring(0, 12), hash, label, p.directoryId(), p.dn(), p.subject(), p.name(), p.via(), roles, grants, + now, now.plus(life), null); + keys.put(hash, k); + save(); + return new Made(token, k); + } + + /** The key {@code token} stands for, whatever its state; null when it is none. */ + public synchronized Key find(String token) { + if (token == null || !token.startsWith(PREFIX) || token.length() > 100) { + return null; + } + return keys.get(sha256(token)); + } + + /** Note a use of {@code k} (written to disk at most every ten minutes); the key as it is now. */ + public synchronized Key used(Key k) throws IOException { + Key current = keys.get(k.hash()); + if (current == null) { + return k; + } + Instant now = clock.instant(); + Key u = current.used(now); + keys.put(k.hash(), u); + if (current.lastUsed() == null || current.lastUsed().plus(TOUCH).isBefore(now)) { + save(); + } + return u; + } + + /** {@code dn}'s keys in {@code directoryId}, newest first. */ + public synchronized List of(String directoryId, String dn) { + return keys.values().stream().filter(k -> k.directoryId().equals(directoryId) && DirectLogin.sameDn(k.dn(), dn)) + .sorted((a, b) -> b.created().compareTo(a.created())).toList(); + } + + /** Every key in {@code directoryId}, newest first. */ + public synchronized List all(String directoryId) { + return keys.values().stream().filter(k -> k.directoryId().equals(directoryId)) + .sorted((a, b) -> b.created().compareTo(a.created())).toList(); + } + + /** The key with this id; null when there is none. */ + public synchronized Key byId(String id) { + return keys.values().stream().filter(k -> k.id().equals(id)).findFirst().orElse(null); + } + + /** Remove the key with this id; the removed key, or null. */ + public synchronized Key revoke(String id) throws IOException { + Key k = byId(id); + if (k != null) { + keys.remove(k.hash()); + save(); + } + return k; + } + + /** Remove every key of a directory that was removed; the ids removed. */ + public synchronized List removeDirectory(String directoryId) throws IOException { + List ids = all(directoryId).stream().map(Key::id).toList(); + if (keys.values().removeIf(k -> k.directoryId().equals(directoryId))) { + save(); + } + return ids; + } + + /** + * A single sign-on person signed in again: their keys take the grants of this sign-in, so a role taken away at + * the provider leaves their keys at once. A key never goes past the roles it was made with. + */ + public synchronized void signedIn(Sessions.Principal p) throws IOException { + boolean changed = false; + for (Map.Entry e : keys.entrySet()) { + Key k = e.getValue(); + if ("oidc".equals(k.via()) && k.directoryId().equals(p.directoryId()) && DirectLogin.sameDn(k.dn(), p.dn())) { + List g = withoutPresets(p.grants()); + if (!g.equals(k.grants())) { + e.setValue(k.withGrants(g)); + changed = true; + } + } + } + if (changed) { + save(); + } + } + + /** + * What a key may do with a person's {@code grants}: each grant of a role the key carries, kept with its scopes; + * a grant of another role reads only (viewer, same scopes); admin becomes each of the key's roles, everywhere. + * Task presets are left out: they are changes. + */ + public static List limit(List grants, Set roles) { + List out = new ArrayList<>(); + for (Grant g : grants) { + if (g.role() == null) { + continue; + } + if (g.role() == Role.ADMIN) { + roles.stream().sorted().forEach(r -> out.add(Grant.everywhere(r))); + } else { + out.add(new Grant(roles.contains(g.role()) ? g.role() : Role.VIEWER, g.scopes(), List.of())); + } + } + return out.stream().distinct().toList(); + } + + /** + * {@code requested} role ids as a key's roles: viewer always, and only roles of the key ceiling the person has + * (admin has them all). Throws IllegalArgumentException naming what is wrong. + */ + public static Set roles(List requested, Sessions.Principal p) { + Set out = EnumSet.of(Role.VIEWER); + for (String r : requested) { + Role role = Role.parse(r); + if (!CEILING.contains(role)) { + throw new IllegalArgumentException("an agent key never carries the " + role.id() + " role"); + } + if (!p.has(role) && role != Role.VIEWER) { + throw new IllegalArgumentException("you do not have the " + role.id() + " role, so your key cannot have it"); + } + out.add(role); + } + return Set.copyOf(out); + } + + private static List withoutPresets(List grants) { + return grants.stream().filter(g -> g.role() != null).map(g -> new Grant(g.role(), g.scopes(), List.of())).toList(); + } + + private void save() throws IOException { + List list = new ArrayList<>(); + for (Key k : keys.values()) { + Map m = new LinkedHashMap<>(); + m.put("id", k.id()); + m.put("hash", k.hash()); + m.put("label", k.label()); + m.put("directory", k.directoryId()); + m.put("dn", k.dn()); + m.put("subject", k.subject()); + m.put("name", k.name()); + m.put("via", k.via()); + m.put("roles", k.roles().stream().map(Role::id).sorted().toList()); + m.put("grants", k.grants().stream().map(g -> Map.of("role", g.role().id(), "scopes", g.scopes())).toList()); + m.put("created", k.created().toString()); + m.put("expires", k.expires().toString()); + if (k.lastUsed() != null) { + m.put("lastUsed", k.lastUsed().toString()); + } + list.add(m); + } + DataDir.write(file, Json.pretty(Map.of("keys", list)).getBytes(StandardCharsets.UTF_8)); + } + + private static Key parse(Map m) { + Set roles = EnumSet.noneOf(Role.class); + for (Object r : Json.asList(m.get("roles"))) { + Role role = Role.parse(String.valueOf(r)); + if (CEILING.contains(role)) { + roles.add(role); + } + } + List grants = new ArrayList<>(); + for (Object o : Json.asList(m.get("grants"))) { + Map g = Json.asMap(o); + grants.add(new Grant(Role.parse(String.valueOf(g.get("role"))), Json.asList(g.get("scopes")).stream().map(String::valueOf).toList(), List.of())); + } + Object used = m.get("lastUsed"); + return new Key(String.valueOf(m.get("id")), String.valueOf(m.get("hash")).toLowerCase(Locale.ROOT), String.valueOf(m.get("label")), + String.valueOf(m.get("directory")), String.valueOf(m.get("dn")), String.valueOf(m.get("subject")), String.valueOf(m.get("name")), + String.valueOf(m.get("via")), roles, grants, Instant.parse(String.valueOf(m.get("created"))), Instant.parse(String.valueOf(m.get("expires"))), + used == null ? null : Instant.parse(String.valueOf(used))); + } + + static String sha256(String token) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.US_ASCII))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/console-server/src/main/java/com/pointblue/console/server/auth/DirectLogin.java b/console-server/src/main/java/com/pointblue/console/server/auth/DirectLogin.java index 485652d..3a505d6 100644 --- a/console-server/src/main/java/com/pointblue/console/server/auth/DirectLogin.java +++ b/console-server/src/main/java/com/pointblue/console/server/auth/DirectLogin.java @@ -174,12 +174,7 @@ public Outcome login(DirectoryConfig d, String serviceAccountPassword, String na if (breakGlass) { grants.add(Grant.everywhere(Role.ADMIN)); } - List groups = me == null ? List.of() : me.attributes().getOrDefault("groupmembership", List.of()); - for (DirectoryConfig.RoleMapping m : d.roles()) { - if (groups.stream().anyMatch(g -> sameDn(String.valueOf(g), m.group()))) { - grants.add(m.grant()); - } - } + grants.addAll(grants(d, me == null ? List.of() : me.attributes().getOrDefault("groupmembership", List.of()))); Set roles = roles(grants); if (grants.isEmpty()) { conn.close(); @@ -207,8 +202,19 @@ public Outcome login(DirectoryConfig d, String serviceAccountPassword, String na } } + /** The grants of {@code d}'s role mappings whose group is among {@code groups} (a person's {@code groupMembership}). */ + public static List grants(DirectoryConfig d, List groups) { + List out = new ArrayList<>(); + for (DirectoryConfig.RoleMapping m : d.roles()) { + if (groups.stream().anyMatch(g -> sameDn(String.valueOf(g), m.group()))) { + out.add(m.grant()); + } + } + return out; + } + /** Every role the grants give, scoped or not. */ - static Set roles(List grants) { + public static Set roles(List grants) { Set roles = EnumSet.noneOf(Role.class); grants.stream().filter(g -> g.role() != null).forEach(g -> roles.add(g.role())); return Set.copyOf(roles); diff --git a/console-server/src/main/java/com/pointblue/console/server/auth/Sessions.java b/console-server/src/main/java/com/pointblue/console/server/auth/Sessions.java index 105c8bb..f403c62 100644 --- a/console-server/src/main/java/com/pointblue/console/server/auth/Sessions.java +++ b/console-server/src/main/java/com/pointblue/console/server/auth/Sessions.java @@ -19,7 +19,8 @@ public final class Sessions { /** - * Who is signed in. {@code via} is {@code direct}, {@code break-glass} or {@code oidc}. {@code roles} + * Who is signed in. {@code via} is {@code direct}, {@code break-glass}, {@code oidc} or {@code agent} (an agent + * key, see {@link AgentKeys}). {@code roles} * are every role any grant gives, scoped or not, for what the UI offers; {@link #may} decides for * an object. */ @@ -34,6 +35,14 @@ public Principal(String subject, String dn, String name, String directoryId, Set this(subject, dn, name, directoryId, roles, via, roles.stream().map(Grant::everywhere).toList()); } + /** + * Whether the console acts as this person on its service account's connection (the proxied-authorization + * control): single sign-on and agent keys. Such a person reaches every server without signing in again. + */ + public boolean proxied() { + return "oidc".equals(via) || "agent".equals(via); + } + /** Whether some grant gives {@code r} (or admin), anywhere. */ public boolean has(Role r) { return roles.contains(r) || roles.contains(Role.ADMIN); @@ -67,11 +76,18 @@ public static final class Session { public final com.pointblue.dirxml.dev.operate.Operate.Engine engine; public final String engineProblem; final AutoCloseable engineHandle; + /** The agent key this session stands for (its label and id), for the audit; null for a browser's session. */ + public final String agent; volatile Instant lastSeen; volatile Instant authenticatedAt; Session(String id, String csrf, Principal principal, Ldap ldap, com.pointblue.console.server.idm.Engines.Opened engine, String engineProblem, Instant now) { + this(id, csrf, principal, ldap, engine, engineProblem, now, null); + } + + Session(String id, String csrf, Principal principal, Ldap ldap, com.pointblue.console.server.idm.Engines.Opened engine, String engineProblem, Instant now, String agent) { this.id = id; + this.agent = agent; this.csrf = csrf; this.principal = principal; this.ldap = ldap; @@ -139,6 +155,16 @@ public Session create(Principal p, Ldap ldap, com.pointblue.console.server.idm.E return s; } + /** + * A session for one agent key's calls, not kept here: the caller (see {@code Agents}) keeps it and closes its + * connections. It never counts as a recent sign-in, so changes that need one are refused. + */ + public static Session agent(Principal p, Ldap ldap, com.pointblue.console.server.idm.Engines.Opened engine, String engineProblem, String agent) { + Session s = new Session(token(), token(), p, ldap, engine, engineProblem, Instant.now(), agent); + s.authenticatedAt = Instant.EPOCH; + return s; + } + /** Where each tree's own limits come from. */ public void limits(java.util.function.Function byDirectory) { this.limits = byDirectory; diff --git a/console-server/src/main/java/com/pointblue/console/server/data/DataDir.java b/console-server/src/main/java/com/pointblue/console/server/data/DataDir.java index a587a0d..5c6b62e 100644 --- a/console-server/src/main/java/com/pointblue/console/server/data/DataDir.java +++ b/console-server/src/main/java/com/pointblue/console/server/data/DataDir.java @@ -31,7 +31,7 @@ public final class DataDir { public DataDir(Path root) throws IOException { this.root = root.toAbsolutePath(); - for (Path d : new Path[] {this.root, config(), history(), trust(), tls(), keys(), audit()}) { + for (Path d : new Path[] {this.root, config(), history(), trust(), tls(), keys(), audit(), agents()}) { directory(d); } } @@ -64,6 +64,11 @@ public Path audit() { return root.resolve("audit"); } + /** Agent keys (their hashes): kept out of backups, so a restore never brings back a revoked key. */ + public Path agents() { + return root.resolve("agents"); + } + public Path bootstrapFile() { return root.resolve("bootstrap.txt"); } diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Agents.java b/console-server/src/main/java/com/pointblue/console/server/http/Agents.java new file mode 100644 index 0000000..e69cd80 --- /dev/null +++ b/console-server/src/main/java/com/pointblue/console/server/http/Agents.java @@ -0,0 +1,464 @@ +package com.pointblue.console.server.http; + +import com.pointblue.console.server.audit.AuditLog; +import com.pointblue.console.server.auth.AgentKeys; +import com.pointblue.console.server.auth.DirectLogin; +import com.pointblue.console.server.auth.DirectoryConfig; +import com.pointblue.console.server.auth.Grant; +import com.pointblue.console.server.auth.Role; +import com.pointblue.console.server.auth.Sessions; +import com.pointblue.console.server.ldap.Ldap; +import com.pointblue.dirxml.dev.json.Json; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import javax.naming.NamingException; + +/** + * Agent keys (docs/MCP.md): the routes a signed-in person makes and revokes them with, and how a key becomes a + * session for the MCP server's calls. + * + *
+ * GET    /api/v2/agent-keys          the person's keys, the directory's settings and the MCP address; ?all=true: everyone's (admin)
+ * POST   /api/v2/agent-keys          {"label", "days"?, "roles"? ["viewer", "editor", "operator"], "password"? (direct sign-in)}
+ *                                    the key, shown once
+ * DELETE /api/v2/agent-keys/{id}     revoke: the person's own key, or anyone's for an admin
+ * 
+ * + * Making a key proves the person is present: direct sign-in types the password again (a bind); single sign-on needs a + * sign-in within the last 10 minutes. The console then checks it can act as them. + * + *

A key's calls run as the person the way single sign-on does: the service account's connection with the RFC 4370 + * proxied-authorization control, and Identity Manager's engine opened by the service account acting as them. eDirectory + * refuses the control for disabled and expired accounts, but accepts it for an account locked by intruder detection, + * so on every call the console reads the person's account as the service account and refuses a locked, disabled or + * expired one. Direct sign-in grants come from that same read ({@code groupMembership}), so a role taken away reaches + * the key at its next call; single sign-on grants are those of the person's last sign-in. A key never carries admin, + * never counts as a recent sign-in, and carries no task presets. + */ +final class Agents { + + /** + * Agent keys for a directory: {@code directories..agentKeys = {"enabled", "maxDays", "approveChanges"}}. Off by + * default. {@code approveChanges} (true by default): a change an agent asks for waits until the person approves it in + * the console. The tools of this version make no changes, so nothing waits yet. + */ + record Settings(boolean enabled, int maxDays, boolean approveChanges) { + Map json() { + return Map.of("enabled", enabled, "maxDays", maxDays, "approveChanges", approveChanges); + } + } + + /** What the person's account is read for on every call. */ + private static final String[] PERSON = {"cn", "fullName", "groupMembership", "loginDisabled", "loginExpirationTime", + "lockedByIntruder", "loginIntruderResetTime"}; + private static final DateTimeFormatter GENERALIZED = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").withZone(ZoneOffset.UTC); + /** An unused key's connections are closed after this long. */ + private static final Duration CONTEXT_IDLE = Duration.ofMinutes(5); + /** A known key refused for the same reason is audited once in this window. */ + private static final Duration REFUSAL_AUDIT = Duration.ofHours(1); + private static final int MAX_LABEL = 60; + + private final Server server; + final AgentKeys keys; + /** Open connections per key id. */ + private final Map contexts = new ConcurrentHashMap<>(); + private final Map refusalsAudited = new ConcurrentHashMap<>(); + + /** A key's connections: the service account plain (to read the person), and acting as the person (for the calls). */ + private static final class Context { + final Ldap svc; + final Ldap ldap; + final com.pointblue.console.server.idm.Engines.Opened engine; + final String engineProblem; + volatile Sessions.Session session; + volatile Instant lastUsed = Instant.now(); + + Context(Ldap svc, Ldap ldap, com.pointblue.console.server.idm.Engines.Opened engine, String engineProblem) { + this.svc = svc; + this.ldap = ldap; + this.engine = engine; + this.engineProblem = engineProblem; + } + + void close() { + Sessions.Session s = session; + if (s != null) { + s.servers.values().forEach(Sessions.ServerConnection::close); + s.servers.clear(); + } + if (engine != null && engine.handle() != null) { + try { + engine.handle().close(); + } catch (Exception ignored) { + // already gone + } + } + ldap.close(); + svc.close(); + } + } + + Agents(Server server, AgentKeys keys) { + this.server = server; + this.keys = keys; + } + + boolean handles(String path) { + return path.equals("/api/v2/agent-keys") || path.startsWith("/api/v2/agent-keys/"); + } + + Settings settings(String directoryId) { + Object dirs = server.config.read().get("directories"); + Object d = dirs instanceof Map m ? m.get(directoryId) : null; + Object a = d instanceof Map dm ? dm.get("agentKeys") : null; + if (!(a instanceof Map am)) { + return new Settings(false, AgentKeys.MAX_DAYS, true); + } + int max = Math.max(1, Math.min(AgentKeys.MAX_DAYS, Json.asInt(am.get("maxDays"), AgentKeys.MAX_DAYS))); + return new Settings(Boolean.TRUE.equals(am.get("enabled")), max, !Boolean.FALSE.equals(am.get("approveChanges"))); + } + + String mcpUrl() { + return server.publicUrl + "/mcp"; + } + + void handle(HttpExchange ex, String method, String path) throws IOException { + Sessions.Session s = server.session(ex, null); + if (s.agent != null) { + throw new Http.Problem(403, "agent-key", "an agent key cannot make or revoke agent keys"); + } + if (path.equals("/api/v2/agent-keys") && method.equals("GET")) { + list(ex, s); + } else if (path.equals("/api/v2/agent-keys") && method.equals("POST")) { + create(ex, s); + } else if (method.equals("DELETE") && path.matches("/api/v2/agent-keys/[0-9a-f]{12}")) { + revoke(ex, s, path.substring("/api/v2/agent-keys/".length())); + } else { + throw new Http.Problem(404, "not-found", method + " " + path); + } + } + + private void list(HttpExchange ex, Sessions.Session s) throws IOException { + boolean all = "true".equals(Http.query(ex).get("all")); + if (all && !s.principal.has(Role.ADMIN)) { + throw new Http.Problem(403, "forbidden", "everyone's keys are for admins"); + } + String dir = s.principal.directoryId(); + Instant now = keys.now(); + Map out = new LinkedHashMap<>(); + out.put("keys", (all ? keys.all(dir) : keys.of(dir, s.principal.dn())).stream().map(k -> k.json(now)).toList()); + Map st = new LinkedHashMap<>(settings(dir).json()); + DirectoryConfig d = server.directoryOf(s); + st.put("serviceAccount", d != null && d.serviceAccountDn() != null); + st.put("longestDays", longest(s, d)); + out.put("settings", st); + out.put("mcpUrl", mcpUrl()); + Http.json(ex, 200, out); + } + + /** The longest life a key of this person may have here. */ + private int longest(Sessions.Session s, DirectoryConfig d) { + int max = d == null ? AgentKeys.MAX_DAYS : settings(d.id()).maxDays(); + return "oidc".equals(s.principal.via()) ? Math.min(max, AgentKeys.SSO_MAX_DAYS) : max; + } + + private void create(HttpExchange ex, Sessions.Session s) throws IOException { + Map b = Http.body(ex); + String client = server.client(ex); + Sessions.Principal p = s.principal; + DirectoryConfig d = DirectoryConfig.find(server.config.read(), p.directoryId()); + if (d == null || !(p.via().equals("direct") || p.via().equals("oidc"))) { + throw new Http.Problem(409, "not-available", "agent keys are for configured directories, signed in directly or with single sign-on"); + } + if (!settings(d.id()).enabled()) { + throw new Http.Problem(409, "agent-keys-off", "agent keys are off for this directory; an administrator turns them on in Settings > Directory"); + } + String svcPw = server.config.secret(d.serviceAccountPasswordPath()); + if (d.serviceAccountDn() == null || svcPw == null) { + throw new Http.Problem(409, "no-service-account", "agent keys need the directory's service account"); + } + if (server.oidc.isNeverProxy(d, p.dn())) { + throw new Http.Problem(409, "never-proxy", "the console never acts as this account (the service account, a break-glass account, or one on the never-proxy list), so it cannot have agent keys"); + } + String label = Http.string(b, "label", true).trim(); + if (label.length() > MAX_LABEL || !label.matches("[\\p{L}\\p{N} ._()/+-]+")) { + throw new Http.Problem(400, "bad-field", "the label is up to " + MAX_LABEL + " letters, digits, spaces and . _ ( ) / + -"); + } + int longest = longest(s, d); + int days = Http.integer(b, "days", Math.min(AgentKeys.DEFAULT_DAYS, longest)); + if (days < 1 || days > longest) { + throw new Http.Problem(400, "bad-field", "days is 1 to " + longest + " here"); + } + Set roles; + try { + roles = AgentKeys.roles(Json.asList(b.get("roles")).stream().map(String::valueOf).toList(), p); + } catch (IllegalArgumentException e) { + throw new Http.Problem(400, "bad-field", e.getMessage()); + } + if (keys.of(d.id(), p.dn()).size() >= AgentKeys.PER_PERSON) { + throw new Http.Problem(409, "too-many-keys", "you have " + AgentKeys.PER_PERSON + " agent keys here; revoke one first"); + } + // the person is here now: their password again, or a recent single sign-on + if (p.via().equals("oidc")) { + server.stepUp(s); + } else { + if (!server.failedByClient.allowed(client)) { + throw new Http.Problem(429, "too-many-attempts", "too many failed sign-ins; wait a few minutes"); + } + char[] pw = Http.string(b, "password", true).toCharArray(); + try (Ldap ignored = Ldap.connect(server.trust, d.target(), null, p.dn(), pw, Server.T)) { + server.failedByClient.clear(client); + } catch (Ldap.BadCredentials e) { + server.failedByClient.record(client); + server.audit.log(Server.who(s), "agent-key.create", d.id(), Map.of("label", label, "client", client), "refused"); + throw new Http.Problem(401, "bad-credentials", "the password is incorrect"); + } catch (Ldap.Untrusted | NamingException e) { + throw new Http.Problem(503, "unavailable", "the directory did not answer: " + e.getMessage()); + } finally { + java.util.Arrays.fill(pw, '\0'); + } + } + // the console can act as them: the same read single sign-on makes before it lets someone in + try (Ldap svc = Ldap.connect(server.trust, d.target(), null, d.serviceAccountDn(), svcPw.toCharArray(), Server.T)) { + svc.actAs(p.dn()); + if (svc.read(p.dn(), "cn") == null) { + throw new NamingException("the account could not be read"); + } + } catch (Ldap.Untrusted | Ldap.BadCredentials | NamingException e) { + throw new Http.Problem(409, "proxy-refused", "the console cannot act as you through its service account (" + + (e instanceof NamingException n ? Ldap.Failure.of(n).getMessage() : e.getMessage()) + + "); it needs Supervisor over your account"); + } + AgentKeys.Made made = keys.create(label, p, roles, Duration.ofDays(days)); + server.audit.log(Server.who(s), "agent-key.create", made.key().id(), Map.of("label", label, "roles", made.key().roles().stream().map(Role::id).sorted().toList(), + "days", days, "client", client), "ok"); + Map out = new LinkedHashMap<>(); + out.put("token", made.token()); + out.put("key", made.key().json(keys.now())); + out.put("mcpUrl", mcpUrl()); + Http.json(ex, 201, out); + } + + private void revoke(HttpExchange ex, Sessions.Session s, String id) throws IOException { + AgentKeys.Key k = keys.byId(id); + boolean mine = k != null && k.directoryId().equals(s.principal.directoryId()) && com.pointblue.console.server.ldap.Names.same(k.dn(), s.principal.dn()); + boolean admin = k != null && k.directoryId().equals(s.principal.directoryId()) && s.principal.has(Role.ADMIN); + if (!mine && !admin) { + throw new Http.Problem(404, "not-found", "no agent key " + id); + } + keys.revoke(id); + close(id); + server.audit.log(Server.who(s), "agent-key.revoke", id, Map.of("label", k.label(), "owner", k.dn()), "ok"); + Http.json(ex, 204, Map.of()); + } + + /** A directory was removed: its keys go too. */ + void removeDirectory(String directoryId) throws IOException { + keys.removeDirectory(directoryId).forEach(this::close); + } + + /** A single sign-on sign-in: the person's keys take its grants. */ + void signedIn(Sessions.Principal p) { + try { + keys.signedIn(p); + } catch (IOException e) { + // the keys keep their earlier grants; the sign-in goes ahead + } + } + + private void close(String id) { + Context c = contexts.remove(id); + if (c != null) { + c.close(); + } + } + + // ---- a key's calls ---------------------------------------------------------------------------- + + /** + * The session an agent key in {@code Authorization: Bearer} stands for, checked now: the key active, the + * directory's agent keys on, the person's account active and unlocked, and some role. Refusals are problems + * (401 for the key itself, with {@code WWW-Authenticate}; 403 for the person or directory). + */ + Caller resolve(HttpExchange ex) throws IOException { + String client = server.client(ex); + String h = ex.getRequestHeaders().getFirst("Authorization"); + String token = h != null && h.regionMatches(true, 0, "Bearer ", 0, 7) ? h.substring(7).trim() : null; + if (token == null || token.isEmpty()) { + throw unauthorized("agent-key-required", "send an agent key: Authorization: Bearer dc_… (made in the console, under your name > Agent keys)"); + } + if (!server.failedByClient.allowed(client)) { + throw new Http.Problem(429, "too-many-attempts", "too many refused keys from this address; wait a few minutes"); + } + AgentKeys.Key k = keys.find(token); + if (k == null) { + server.failedByClient.record(client); + throw unauthorized("agent-key-invalid", "this agent key is unknown or was revoked"); + } + Instant now = keys.now(); + if (k.expired(now)) { + throw refused(k, client, unauthorized("agent-key-expired", "this agent key expired on " + k.expires() + "; make a new one")); + } + if (k.idle(now)) { + throw refused(k, client, unauthorized("agent-key-idle", "this agent key was not used for " + AgentKeys.IDLE.toDays() + " days and stopped working; make a new one")); + } + DirectoryConfig d = DirectoryConfig.find(server.config.read(), k.directoryId()); + if (d == null) { + throw unauthorized("agent-key-invalid", "this agent key's directory was removed"); + } + if (!settings(d.id()).enabled()) { + throw refused(k, client, new Http.Problem(403, "agent-keys-off", "agent keys are off for " + d.name())); + } + if (server.oidc.isNeverProxy(d, k.dn())) { + throw refused(k, client, new Http.Problem(403, "never-proxy", "the console never acts as " + k.dn())); + } + Context c; + Ldap.Entry me; + try { + c = context(k, d); + try { + me = c.svc.read(k.dn(), PERSON); + } catch (javax.naming.CommunicationException | javax.naming.ServiceUnavailableException e) { + // the directory closed an idle connection: open it again, once + close(k.id()); + c = context(k, d); + me = c.svc.read(k.dn(), PERSON); + } + } catch (NamingException e) { + close(k.id()); + throw new Http.Problem(503, "unavailable", "the directory did not answer: " + Ldap.Failure.of(e).getMessage()); + } + if (me == null) { + throw refused(k, client, new Http.Problem(403, "no-account", k.dn() + " is not in the directory")); + } + String refusal = inactive(me, now); + if (refusal != null) { + throw refused(k, client, new Http.Problem(403, refusal, switch (refusal) { + case "locked" -> "the account is locked by intruder detection; its agent keys work again once it is unlocked"; + case "account-disabled" -> "the account is disabled"; + default -> "the account has expired"; + })); + } + List grants = "oidc".equals(k.via()) ? k.grants() + : DirectLogin.grants(d, me.attributes().getOrDefault("groupmembership", List.of())); + List limited = AgentKeys.limit(grants, k.roles()); + if (limited.isEmpty()) { + throw refused(k, client, new Http.Problem(403, "no-role", "the account has no Directory Console role now")); + } + String name = me.first("fullName") != null ? me.first("fullName") : me.first("cn") != null ? me.first("cn") : k.name(); + Sessions.Principal p = new Sessions.Principal(k.subject(), k.dn(), name, d.id(), DirectLogin.roles(limited), "agent", limited); + Sessions.Session s = c.session; + if (s == null || !s.principal.equals(p)) { + Sessions.Session made = Sessions.agent(p, c.ldap, c.engine, c.engineProblem, k.ref()); + if (s != null) { + made.servers.putAll(s.servers); + } + c.session = made; + s = made; + } + c.lastUsed = now; + sweep(now); + return new Caller(s, keys.used(k)); + } + + /** An agent key's call: the session it runs in, and the key. */ + record Caller(Sessions.Session session, AgentKeys.Key key) { + } + + /** Why the account cannot be acted as now ({@code locked}, {@code account-disabled}, {@code account-expired}), or null. */ + String inactive(Ldap.Entry me, Instant now) { + if ("TRUE".equalsIgnoreCase(me.first("loginDisabled"))) { + return "account-disabled"; + } + String exp = me.first("loginExpirationTime"); + if (exp != null) { + try { + if (!Instant.from(GENERALIZED.parse(exp.trim())).isAfter(now)) { + return "account-expired"; + } + } catch (DateTimeParseException e) { + // eDirectory decides; the proxied control is refused for an expired account + } + } + return server.oidc.locked(me) ? "locked" : null; + } + + private Context context(AgentKeys.Key k, DirectoryConfig d) throws NamingException { + Context c = contexts.get(k.id()); + if (c != null) { + return c; + } + synchronized (this) { + c = contexts.get(k.id()); + if (c != null) { + return c; + } + String pw = server.config.secret(d.serviceAccountPasswordPath()); + if (d.serviceAccountDn() == null || pw == null) { + throw new Http.Problem(503, "no-service-account", "agent keys need the directory's service account"); + } + Ldap svc; + Ldap ldap; + try { + svc = Ldap.connect(server.trust, d.target(), null, d.serviceAccountDn(), pw.toCharArray(), Server.T); + } catch (Ldap.Untrusted | Ldap.BadCredentials e) { + throw new Http.Problem(503, "unavailable", "the console's service account could not sign in: " + e.getMessage()); + } + try { + ldap = Ldap.connect(server.trust, d.target(), null, d.serviceAccountDn(), pw.toCharArray(), Server.T); + } catch (Ldap.Untrusted | Ldap.BadCredentials e) { + svc.close(); + throw new Http.Problem(503, "unavailable", "the console's service account could not sign in: " + e.getMessage()); + } catch (NamingException e) { + svc.close(); + throw e; + } + ldap.actAs(k.dn()); + Server.SsoEngine e = server.ssoEngine(d, ldap, d.target(), k.dn(), null); + c = new Context(svc, ldap, e.engine(), e.problem()); + contexts.put(k.id(), c); + return c; + } + } + + /** Close the connections of keys not used lately. */ + private void sweep(Instant now) { + for (Map.Entry e : new ArrayList<>(contexts.entrySet())) { + if (e.getValue().lastUsed.plus(CONTEXT_IDLE).isBefore(now) && contexts.remove(e.getKey(), e.getValue())) { + e.getValue().close(); + } + } + } + + private Http.Problem unauthorized(String code, String detail) { + return new Http.Problem(401, code, detail); + } + + /** A known key refused: audited once an hour per key and reason. */ + private Http.Problem refused(AgentKeys.Key k, String client, Http.Problem p) throws IOException { + Instant now = keys.now(); + String key = k.id() + "|" + p.type; + Instant last = refusalsAudited.get(key); + if (last == null || last.plus(REFUSAL_AUDIT).isBefore(now)) { + refusalsAudited.put(key, now); + server.audit.log(new AuditLog.Actor(k.subject(), k.dn(), "agent", k.ref()), "agent-key.use", k.id(), Map.of("code", p.type, "client", client), "refused"); + } + return p; + } + + /** Close every key's connections (the server stops). */ + void stop() { + new ArrayList<>(contexts.keySet()).forEach(this::close); + } +} diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Idm.java b/console-server/src/main/java/com/pointblue/console/server/http/Idm.java index 0f54715..dc1d6b5 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Idm.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Idm.java @@ -603,7 +603,7 @@ private void deleteEvents(HttpExchange ex, Sessions.Session s, Operate.Engine en results.add(r); } long deleted = results.stream().filter(r -> "deleted".equals(r.get("status"))).count(); - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), "idm.driver.cache-delete", guid, + server.audit.log(Server.who(s), "idm.driver.cache-delete", guid, Map.of("dn", t.dn(), "directory", s.principal.directoryId(), "events", results), deleted == results.size() ? "ok" : deleted == 0 ? "failed" : "partial"); Http.json(ex, 200, Map.of("results", results, "deleted", deleted)); } @@ -628,7 +628,7 @@ private List> runningServers(Sessions.Session s, Ldap l, Ope r.put("dn", e.dn()); r.put("name", e.first("cn")); r.put("connected", Names.same(e.dn(), own)); - r.put("signedIn", Names.same(e.dn(), own) || s.servers.containsKey(g) || "oidc".equals(s.principal.via())); + r.put("signedIn", Names.same(e.dn(), own) || s.servers.containsKey(g) || s.principal.proxied()); out.add(r); } } @@ -706,7 +706,7 @@ private void acted(HttpExchange ex, Sessions.Session s, String action, String gu private void acted(HttpExchange ex, Sessions.Session s, String action, String guid, Target t, Operate.Result r, boolean perObject) throws IOException { Object body = parse(r); boolean refused = !r.ok && body instanceof Map m && m.get("refusal") != null; - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), action, guid, + server.audit.log(Server.who(s), action, guid, Map.of("dn", t.dn(), "directory", s.principal.directoryId()), refused ? "refused" : r.ok ? "ok" : "failed"); if (refused) { throw new Http.Problem(409, "confirm-directory", String.valueOf(((Map) body).get("refusal"))); @@ -759,7 +759,7 @@ private void setPassword(HttpExchange ex, Sessions.Session s, DirectoryConfig d, } finally { java.util.Arrays.fill(value, '\0'); } - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), "idm.password.set", guid, + server.audit.log(Server.who(s), "idm.password.set", guid, Map.of("dn", t.dn(), "kind", kind, "name", name == null ? "" : name), "ok"); Http.json(ex, 204, Map.of()); } @@ -776,7 +776,7 @@ private void removePassword(HttpExchange ex, Sessions.Session s, DirectoryConfig case "key" -> engine.clearMutualAuthKeyPassword(t.dn()); default -> engine.clearMutualAuthKeystorePassword(t.dn()); } - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), "idm.password.remove", guid, + server.audit.log(Server.who(s), "idm.password.remove", guid, Map.of("dn", t.dn(), "kind", kind, "name", name == null ? "" : name), "ok"); Http.json(ex, 204, Map.of()); } diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Mcp.java b/console-server/src/main/java/com/pointblue/console/server/http/Mcp.java new file mode 100644 index 0000000..9f7786e --- /dev/null +++ b/console-server/src/main/java/com/pointblue/console/server/http/Mcp.java @@ -0,0 +1,531 @@ +package com.pointblue.console.server.http; + +import com.pointblue.console.server.auth.Sessions; +import com.pointblue.dirxml.dev.json.Json; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The console as an MCP server for agents (docs/MCP.md): JSON-RPC 2.0 over HTTP POST at {@code /mcp}, stateless, + * the Streamable HTTP transport without a server-sent stream ({@code GET} answers 405, as the specification allows). + * Each call carries an agent key ({@code Authorization: Bearer dc_…}, see {@link Agents}). + * + *

Every tool is a call to the console's own API, made in-process with the key's session: the same roles, container + * scopes, eDirectory rights and refusals as the page. Nothing here decides what an agent may do; the API does. The + * tools of this version only read. + */ +final class Mcp { + + static final List PROTOCOLS = List.of("2025-06-18", "2025-03-26", "2024-11-05"); + private static final int MAX_TEXT = 200_000; + private static final String GUID = "[0-9a-fA-F]{32}"; + + private static final String INSTRUCTIONS = String.join("\n", + "Directory Console: eDirectory and Identity Manager administration, as the person who made this agent key, with their own eDirectory rights.", + "1. Call whoami first: who you act as, the directory (and whether it is production), and what the key may do.", + "2. Objects, drivers, driver sets and jobs are named by GUID or by DN. list_driver_sets and driver_set give the drivers' GUIDs.", + "3. The tools of this version only read. Report what you find; changes are made by the person in the console.", + "4. Never ask for a password or print one; tools never return secrets."); + + private final Server server; + private final List tools = new ArrayList<>(); + + Mcp(Server server) { + this.server = server; + define(); + } + + // ---- the transport ---- + + void handle(HttpExchange ex, String method) throws IOException { + if (!method.equals("POST")) { + ex.getResponseHeaders().set("Allow", "POST"); + throw new Http.Problem(405, "method-not-allowed", "POST JSON-RPC to this endpoint; no server-sent stream is offered"); + } + Agents.Caller caller; + try { + caller = server.agents.resolve(ex); + } catch (Http.Problem p) { + if (p.status == 401) { + ex.getResponseHeaders().set("WWW-Authenticate", "Bearer realm=\"Directory Console\", error=\"invalid_token\""); + } + throw p; + } + byte[] raw; + try (InputStream in = ex.getRequestBody()) { + raw = in.readNBytes(Http.MAX_BODY + 1); + } + if (raw.length > Http.MAX_BODY) { + throw new Http.Problem(413, "too-large", "the request body is larger than " + Http.MAX_BODY + " bytes"); + } + Object parsed; + try { + parsed = Json.parse(new String(raw, StandardCharsets.UTF_8)); + } catch (RuntimeException e) { + send(ex, 400, error(null, -32700, "not JSON: " + e.getMessage())); + return; + } + if (parsed instanceof List batch) { + List out = new ArrayList<>(); + for (Object o : batch) { + Object r = message(ex, caller, o); + if (r != null) { + out.add(r); + } + } + if (out.isEmpty()) { + accepted(ex); + } else { + send(ex, 200, out); + } + return; + } + Object r = message(ex, caller, parsed); + if (r == null) { + accepted(ex); + } else { + send(ex, 200, r); + } + } + + /** One JSON-RPC message: a response, or null for a notification. */ + private Object message(HttpExchange ex, Agents.Caller caller, Object o) { + if (!(o instanceof Map)) { + return error(null, -32600, "a JSON-RPC message is an object"); + } + Map m = Json.asMap(o); + Object id = m.get("id"); + String method = Json.asString(m.get("method")); + if (method == null) { + return id == null ? null : error(id, -32600, "no method"); + } + if (id == null) { + return null; // notifications/initialized, notifications/cancelled: nothing to answer + } + Map params = m.get("params") instanceof Map p ? Json.asMap(p) : Map.of(); + try { + switch (method) { + case "initialize": { + String asked = Json.asString(params.get("protocolVersion")); + Map r = new LinkedHashMap<>(); + r.put("protocolVersion", asked != null && PROTOCOLS.contains(asked) ? asked : PROTOCOLS.get(0)); + r.put("capabilities", Map.of("tools", Map.of("listChanged", false))); + r.put("serverInfo", Map.of("name", "directory-console", "version", server.updates.version())); + r.put("instructions", INSTRUCTIONS); + return result(id, r); + } + case "ping": + return result(id, Map.of()); + case "tools/list": { + List list = new ArrayList<>(); + for (Tool t : tools) { + Map d = new LinkedHashMap<>(); + d.put("name", t.name); + d.put("description", t.description); + d.put("inputSchema", t.schema()); + d.put("annotations", Map.of("readOnlyHint", true, "openWorldHint", false)); + list.add(d); + } + return result(id, Map.of("tools", list)); + } + case "tools/call": { + String name = Json.asString(params.get("name")); + Map args = params.get("arguments") instanceof Map a ? Json.asMap(a) : Map.of(); + for (Tool t : tools) { + if (t.name.equals(name)) { + return result(id, run(ex, caller, t, args)); + } + } + return error(id, -32602, "no tool '" + name + "'"); + } + default: + return error(id, -32601, "method not found: " + method); + } + } catch (RuntimeException | IOException e) { + return error(id, -32603, String.valueOf(e.getMessage())); + } + } + + private Map run(HttpExchange ex, Agents.Caller caller, Tool t, Map args) throws IOException { + Reply r; + String missing = t.missing(args); + if (missing != null) { + r = new Reply(400, Json.compact(Map.of("title", "missing-field", "detail", missing))); + } else { + try { + r = t.call.run(new Call(ex, caller, args)); + } catch (Refusal f) { + r = f.reply; + } + } + String text = r.body; + if (text.length() > MAX_TEXT) { + text = text.substring(0, MAX_TEXT) + "\n… cut at " + MAX_TEXT + " characters; narrow the request (fewer attributes, a smaller count)"; + } + Map out = new LinkedHashMap<>(); + out.put("content", List.of(Map.of("type", "text", "text", text))); + out.put("isError", r.status >= 400); + return out; + } + + // ---- the tools: each a call to the console's own API ---- + + record Reply(int status, String body) { + Map json() { + Object o = body.isBlank() ? Map.of() : Json.parse(body); + return o instanceof Map ? Json.asMap(o) : Map.of("items", o); + } + } + + /** A tool's answer that is a refusal from a step before its own call (an object that is not there). */ + private static final class Refusal extends RuntimeException { + final Reply reply; + + Refusal(Reply reply) { + super(null, null, false, false); + this.reply = reply; + } + } + + private interface Body { + Reply run(Call c) throws IOException; + } + + /** {@code args}: name → [type, description]; {@code required} are among them. */ + private record Tool(String name, String description, Map args, List required, Body call) { + + Map schema() { + Map props = new LinkedHashMap<>(); + args.forEach((k, v) -> { + Map p = new LinkedHashMap<>(); + if (v[0].equals("string[]")) { + p.put("type", "array"); + p.put("items", Map.of("type", "string")); + } else { + p.put("type", v[0]); + } + p.put("description", v[1]); + props.put(k, p); + }); + Map s = new LinkedHashMap<>(); + s.put("type", "object"); + s.put("properties", props); + if (!required.isEmpty()) { + s.put("required", required); + } + s.put("additionalProperties", false); + return s; + } + + String missing(Map a) { + for (String r : required) { + Object v = a.get(r); + if (v == null || (v instanceof String str && str.isBlank())) { + return r + " is required"; + } + } + return null; + } + } + + /** One tool call: its arguments, and in-process calls to the API as the key's session. */ + final class Call { + final HttpExchange ex; + final Agents.Caller caller; + final Map args; + + Call(HttpExchange ex, Agents.Caller caller, Map args) { + this.ex = ex; + this.caller = caller; + this.args = args; + } + + String s(String k) { + Object v = args.get(k); + return v == null || String.valueOf(v).isBlank() ? null : String.valueOf(v); + } + + /** The directory's API base: {@code /api/v2/trees/{id}}. */ + String tree() { + return "/api/v2/trees/" + enc(caller.session().principal.directoryId()); + } + + /** A query string from the arguments named (those given), with {@code ?} first; empty when none. */ + String query(String... names) { + StringBuilder q = new StringBuilder(); + for (String n : names) { + String v = s(n); + if (v != null) { + q.append(q.isEmpty() ? '?' : '&').append(n).append('=').append(enc(v)); + } + } + return q.toString(); + } + + /** The GUID the argument names: a GUID as given, or a DN looked up (a refusal when there is no such object). */ + String guid(String arg) throws IOException { + String v = s(arg); + if (v.matches(GUID)) { + return v.toLowerCase(java.util.Locale.ROOT); + } + Reply r = get(tree() + "/objects?dn=" + enc(v)); + if (r.status != 200) { + throw new Refusal(r); + } + return String.valueOf(r.json().get("guid")); + } + + Reply get(String path) throws IOException { + return call("GET", path, null); + } + + Reply post(String path, Object body) throws IOException { + return call("POST", path, body); + } + + private Reply call(String method, String path, Object body) throws IOException { + byte[] b = body == null ? new byte[0] : Json.compact(body).getBytes(StandardCharsets.UTF_8); + Local l = new Local(ex, caller.session(), method, URI.create(path), b); + server.dispatch(l, l.uri.getPath(), method); + return new Reply(l.status, l.out.toString(StandardCharsets.UTF_8)); + } + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static final String OBJECT = "the object: its GUID, or its DN (cn=jsmith,ou=users,o=acme)"; + private static final String DRIVER = "the driver: its GUID, or its DN (cn=AD,cn=DriverSet,o=system)"; + private static final String DRIVER_SET = "the driver set: its GUID, or its DN (cn=DriverSet,o=system)"; + private static final String SERVER = "optional: an eDirectory server's GUID, to ask that server's replica or engine (object_servers lists them); the key's own server when absent"; + + private void tool(String name, String description, Map args, List required, Body call) { + tools.add(new Tool(name, description, args, required, call)); + } + + private static Map args(String... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 3) { + m.put(kv[i], new String[] {kv[i + 1], kv[i + 2]}); + } + return m; + } + + private void define() { + tool("whoami", "Who this key acts as: the person, the directory and its tier (production or lab), the roles the key may use now, " + + "and the key's label and expiry.", args(), List.of(), c -> { + Reply r = c.get("/api/v2/session"); + if (r.status != 200) { + return r; + } + Map me = new LinkedHashMap<>(r.json()); + me.remove("csrf"); + me.remove("session"); + Map key = new LinkedHashMap<>(c.caller.key().json(server.agents.keys.now())); + key.remove("dn"); + key.remove("name"); + me.put("key", key); + return new Reply(200, Json.compact(me)); + }); + + // ---- directory objects ---- + tool("search_objects", "Search the directory with an LDAP filter, with the person's own rights. Answers a window of matching rows " + + "(GUID, DN, class) and the attributes asked for, with the total.", args( + "filter", "string", "one parenthesized LDAP filter, such as (&(objectClass=inetOrgPerson)(sn=Smith*))", + "base", "string", "optional: the DN to search under; the whole directory when absent", + "scope", "string", "optional: base, one or subtree (the default)", + "attributes", "string[]", "optional: attribute names to return with each row", + "offset", "integer", "optional: the first row, from 1", + "count", "integer", "optional: how many rows, up to 500 (50 by default)"), + List.of("filter"), c -> { + Map b = new LinkedHashMap<>(); + b.put("ldapFilter", c.s("filter")); + if (c.s("base") != null) { + b.put("base", Map.of("dn", c.s("base"))); + } + b.put("scope", c.s("scope") == null ? "subtree" : c.s("scope")); + if (c.args.get("attributes") instanceof List a) { + b.put("attributes", a); + } + Map page = new LinkedHashMap<>(); + page.put("offset", c.args.getOrDefault("offset", 1)); + page.put("count", c.args.getOrDefault("count", 50)); + b.put("page", page); + return c.post(c.tree() + "/search", b); + }); + tool("read_object", "Read one object: every attribute the person may read (secret attributes are shown only as set), its identity and " + + "what the person may do with it.", args("object", "string", OBJECT, "server", "string", SERVER), + List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + c.query("server"))); + tool("list_children", "List the objects in a container, sorted, a window at a time.", args( + "object", "string", "the container: its GUID or DN", + "kind", "string", "optional: all (the default), containers or leaves", + "offset", "integer", "optional: the first row, from 1", + "count", "integer", "optional: how many rows, up to 500"), + List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + "/children" + c.query("kind", "offset", "count"))); + tool("object_servers", "The eDirectory servers holding a replica of an object, with their GUIDs (for the server argument of other tools).", + args("object", "string", OBJECT), List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + "/servers")); + tool("account_status", "A user's sign-in status: disabled, intruder lockout, expiry, grace logins, password expiry and the login time " + + "restrictions. For 'why can this person not sign in'.", args("object", "string", "the user: GUID or DN"), + List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + "/account")); + tool("group_memberships", "The groups a user is a member of.", args("object", "string", "the user: GUID or DN"), + List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + "/memberships")); + tool("group_members", "A group's members, a window at a time.", args( + "object", "string", "the group: GUID or DN", + "offset", "integer", "optional: the first member, from 1", + "count", "integer", "optional: how many, up to 500"), + List.of("object"), c -> c.get(c.tree() + "/objects/" + c.guid("object") + "/members" + c.query("offset", "count"))); + + // ---- Identity Manager ---- + tool("object_associations", "The object inspector: an object's Identity Manager associations (driver, state, association value) and its " + + "password synchronization status. For 'why did this user not synchronize'.", args("object", "string", OBJECT), + List.of("object"), c -> c.get(c.tree() + "/idm/objects/" + c.guid("object") + "/associations")); + tool("idm_status", "Whether Identity Manager's engine can be reached as this person, and its version.", args(), List.of(), + c -> c.get(c.tree() + "/idm")); + tool("list_driver_sets", "The driver sets in the directory, with their GUIDs.", args("server", "string", SERVER), List.of(), + c -> c.get(c.tree() + "/idm/driversets" + c.query("server"))); + tool("driver_set", "A driver set's drivers: each one's GUID, state (running, stopped), start option, cache and trace level.", + args("driver_set", "string", DRIVER_SET, "server", "string", SERVER), List.of("driver_set"), + c -> c.get(c.tree() + "/idm/driversets/" + c.guid("driver_set") + c.query("server"))); + tool("driver_status", "One driver's status: its state, start option, the server it runs on and more.", + args("driver", "string", DRIVER, "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + c.query("server"))); + tool("driver_health", "A driver's health: the engine's health state and the conditions behind it.", + args("driver", "string", DRIVER, "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/health" + c.query("server"))); + tool("list_jobs", "The Identity Manager jobs of a driver set, or of one driver, with their state.", args( + "driver_set", "string", "optional: the driver set (GUID or DN); give it or driver", + "driver", "string", "optional: one driver (GUID or DN)", + "server", "string", SERVER), List.of(), c -> { + if (c.s("driver") != null) { + return c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/jobs" + c.query("server")); + } + if (c.s("driver_set") == null) { + return new Reply(400, Json.compact(Map.of("title", "missing-field", "detail", "driver_set or driver is required"))); + } + return c.get(c.tree() + "/idm/driversets/" + c.guid("driver_set") + "/jobs" + c.query("server")); + }); + tool("job_definition", "A job's definition: its type, schedule, servers, scope and parameters.", + args("job", "string", "the job: GUID or DN"), List.of("job"), + c -> c.get(c.tree() + "/idm/jobs/" + c.guid("job") + "/definition")); + tool("driver_password_sync", "A driver's password synchronization settings, as the engine reports them.", + args("driver", "string", DRIVER, "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/password-sync" + c.query("server"))); + tool("driver_associations", "The association inspector: the objects a driver is associated with, optionally in one state.", args( + "driver", "string", DRIVER, + "state", "string", "optional: disabled, processed, pending, manual or migrate", + "limit", "integer", "optional: at most this many, up to 1000 (200 by default)", + "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/associations" + c.query("state", "limit", "server"))); + tool("driver_cache", "The cache inspector's summary: a driver's cache size, unprocessed size, oldest and newest transaction and counts " + + "by type (a running driver too).", args("driver", "string", DRIVER, "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/cache/summary" + c.query("server"))); + tool("driver_cache_events", "The events waiting in a driver's cache, each with its position (the engine shows them for a stopped driver only).", + args("driver", "string", DRIVER, + "position", "integer", "optional: where to start (0, or a position from an earlier answer)", + "count", "integer", "optional: how many events, up to 100 (25 by default)", + "server", "string", SERVER), List.of("driver"), + c -> c.get(c.tree() + "/idm/drivers/" + c.guid("driver") + "/cache/events" + c.query("position", "count", "server"))); + tool("driver_set_statistics", "A driver set's statistics on one server: the engine's JVM, and each driver's state, cache and operation counters.", + args("driver_set", "string", DRIVER_SET, "server", "string", SERVER), List.of("driver_set"), + c -> c.get(c.tree() + "/idm/driversets/" + c.guid("driver_set") + "/statistics" + c.query("server"))); + tool("versions", "Versions on one server: the engine's, and each driver's shim version and base package as the statistics job last collected them.", + args("driver_set", "string", DRIVER_SET, "server", "string", SERVER), List.of("driver_set"), + c -> c.get(c.tree() + "/idm/driversets/" + c.guid("driver_set") + "/versions" + c.query("server"))); + } + + /** For tests and the docs: the tools' names, in order. */ + List toolNames() { + return tools.stream().map(Tool::name).toList(); + } + + // ---- an in-process request ---- + + /** A request to the API made here, for an agent key's session: {@link Server#session} answers with that session. */ + static final class Local extends HttpExchange { + final HttpExchange from; + final Sessions.Session agent; + final String method; + final URI uri; + final Headers requestHeaders = new Headers(); + final Headers responseHeaders = new Headers(); + final InputStream in; + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + private final Map attributes = new HashMap<>(); + int status = 500; + + Local(HttpExchange from, Sessions.Session agent, String method, URI uri, byte[] body) { + this.from = from; + this.agent = agent; + this.method = method; + this.uri = uri; + this.in = new ByteArrayInputStream(body); + if (body.length > 0) { + requestHeaders.set("Content-Type", "application/json"); + } + requestHeaders.set("Host", String.valueOf(from.getRequestHeaders().getFirst("Host"))); + } + + @Override public Headers getRequestHeaders() { return requestHeaders; } + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public URI getRequestURI() { return uri; } + @Override public String getRequestMethod() { return method; } + @Override public HttpContext getHttpContext() { return from.getHttpContext(); } + @Override public void close() { } + @Override public InputStream getRequestBody() { return in; } + @Override public OutputStream getResponseBody() { return out; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { status = rCode; } + @Override public InetSocketAddress getRemoteAddress() { return from.getRemoteAddress(); } + @Override public int getResponseCode() { return status; } + @Override public InetSocketAddress getLocalAddress() { return from.getLocalAddress(); } + @Override public String getProtocol() { return from.getProtocol(); } + @Override public Object getAttribute(String name) { return attributes.get(name); } + @Override public void setAttribute(String name, Object value) { attributes.put(name, value); } + @Override public void setStreams(InputStream i, OutputStream o) { } + @Override public HttpPrincipal getPrincipal() { return null; } + } + + // ---- JSON-RPC ---- + + private static Map result(Object id, Object result) { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", id); + m.put("result", result); + return m; + } + + private static Map error(Object id, int code, String message) { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", id); + m.put("error", Map.of("code", code, "message", message)); + return m; + } + + private static void accepted(HttpExchange ex) throws IOException { + Http.finishReading(ex); + ex.sendResponseHeaders(202, -1); + ex.close(); + } + + private static void send(HttpExchange ex, int status, Object body) throws IOException { + Http.json(ex, status, body); + } +} diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Objects.java b/console-server/src/main/java/com/pointblue/console/server/http/Objects.java index a60ba31..308e67d 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Objects.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Objects.java @@ -298,7 +298,7 @@ private static Map attributeJson(Schema.AttributeType a) { */ Exception explainRefusal(Sessions.Session s, Ldap l, String dn, List attributes, NamingException x) { Ldap.Failure f = Ldap.Failure.of(x); - if (f.code != 16 || !"oidc".equals(s.principal.via())) { + if (f.code != 16 || !s.principal.proxied()) { return x; } List denied = new ArrayList<>(); @@ -427,7 +427,7 @@ private void holders(HttpExchange ex, Ldap l, Sessions.Session s, String guid) t m.put("name", h.name()); m.put("type", h.type().id()); m.put("connected", Names.same(h.dn(), own)); - m.put("signedIn", Names.same(h.dn(), own) || s.servers.containsKey(h.guid()) || "oidc".equals(s.principal.via())); + m.put("signedIn", Names.same(h.dn(), own) || s.servers.containsKey(h.guid()) || s.principal.proxied()); out.add(m); } Http.json(ex, 200, out); @@ -982,6 +982,6 @@ void audit(Sessions.Session s, String action, String target, Map d.put("ndsError", failed.ndsError); } } - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), action, target, d, failed == null ? "ok" : "refused"); + server.audit.log(Server.who(s), action, target, d, failed == null ? "ok" : "refused"); } } diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Server.java b/console-server/src/main/java/com/pointblue/console/server/http/Server.java index 59a6054..4ee72e5 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Server.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Server.java @@ -79,6 +79,8 @@ public final class Server { private final Settings settings = new Settings(this); private final Edir edir = new Edir(this); final Security security = new Security(this); + final Agents agents; + private final Mcp mcp = new Mcp(this); private final StaticFiles ui = new StaticFiles("web"); /** Open the data directory and listen on {@code port}, every address. {@code publicUrl} is how browsers reach this server. */ @@ -98,6 +100,7 @@ public Server(DataDir data, InetSocketAddress bind, String publicUrl, PrintStrea this.config = new ConfigStore(data, SecretBox.open(data.keys().resolve("config.key"))); this.sessions.limits(this::sessionLimits); this.audit = new AuditLog(data); + this.agents = new Agents(this, new com.pointblue.console.server.auth.AgentKeys(data)); if (restored != null) { audit.log(AuditLog.Actor.SYSTEM, "console.restored", "console", restored, "ok"); out.println(java.time.Instant.now() + " restored from a backup made " + restored.get("createdAt")); @@ -156,6 +159,7 @@ public void stop() { expiry.stop(); syslog.stop(); acme.stop(); + agents.stop(); http.stop(1); } @@ -217,23 +221,36 @@ public void close() { checkHost(ex); } checkOrigin(ex, method); - route(ex, path, method); } catch (Http.Problem p) { Http.problem(ex, p); - } catch (RuntimeException e) { - String ref = logError(method, path, e); - Http.problem(ex, new Http.Problem(500, "internal", "unexpected error: " + e.getClass().getSimpleName() - + (e.getMessage() == null ? "" : " (" + e.getMessage() + ")") + "; the console's log has the details, reference " + ref)); - } catch (LinkageError e) { - // a library class is missing: answer, never drop the connection - String ref = logError(method, path, e); - Http.problem(ex, new Http.Problem(500, "missing-library", "a library this needs is not installed: " + e.getMessage() + "; reference " + ref)); + return; } + dispatch(ex, path, method); } catch (IOException e) { // the client went away } } + /** + * Answer one request: the route's answer, or its refusal as a problem document. The MCP server calls this for each + * tool call, with an in-process exchange carrying the agent key's session (see {@link Mcp.Local}). + */ + void dispatch(HttpExchange ex, String path, String method) throws IOException { + try { + route(ex, path, method); + } catch (Http.Problem p) { + Http.problem(ex, p); + } catch (RuntimeException e) { + String ref = logError(method, path, e); + Http.problem(ex, new Http.Problem(500, "internal", "unexpected error: " + e.getClass().getSimpleName() + + (e.getMessage() == null ? "" : " (" + e.getMessage() + ")") + "; the console's log has the details, reference " + ref)); + } catch (LinkageError e) { + // a library class is missing: answer, never drop the connection + String ref = logError(method, path, e); + Http.problem(ex, new Http.Problem(500, "missing-library", "a library this needs is not installed: " + e.getMessage() + "; reference " + ref)); + } + } + private void headers(HttpExchange ex) { var h = ex.getResponseHeaders(); h.set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"); @@ -284,6 +301,17 @@ private void route(HttpExchange ex, String path, String method) throws IOExcepti } return; } + if (path.equals("/mcp")) { + // agents, with an agent key (see Mcp and Agents); the in-process calls it makes come back through dispatch + if (bootstrap != null) { + throw new Http.Problem(503, "setup-required", "Directory Console is not configured yet"); + } + if (ex instanceof Mcp.Local) { + throw new Http.Problem(404, "not-found", "a tool call cannot call the MCP server"); + } + mcp.handle(ex, method); + return; + } if (!path.startsWith("/api/")) { // the web UI, in bootstrap mode too (it then shows where to find the setup token) ui.serve(ex, path, method); @@ -323,6 +351,10 @@ private void route(HttpExchange ex, String path, String method) throws IOExcepti objects.handle(ex, path, method); return; } + if (agents.handles(path)) { + agents.handle(ex, method, path); + return; + } if (updates.handles(path)) { updates.handle(ex, method, path); return; @@ -387,8 +419,18 @@ private void route(HttpExchange ex, String path, String method) throws IOExcepti // ---- sessions ------------------------------------------------------------------------------- - /** The caller's session, refusing without one, without {@code role}, or without the CSRF token on a write. */ + /** + * The caller's session, refusing without one, without {@code role}, or without the CSRF token on a write. A call the + * MCP server makes for an agent key carries that key's session, checked when the call came in; it sends no cookie, + * so it needs no CSRF token. + */ Sessions.Session session(HttpExchange ex, Role role) { + if (ex instanceof Mcp.Local local) { + if (role != null && !local.agent.principal.has(role)) { + throw new Http.Problem(403, "forbidden", "this needs the " + role.id() + " role, which this agent key does not have"); + } + return local.agent; + } Sessions.Session s = sessions.get(Http.cookie(ex, COOKIE)); if (s == null) { throw new Http.Problem(401, "sign-in-required", "sign in first"); @@ -415,14 +457,14 @@ void stepUp(Sessions.Session s) { } } - /** The audit actor for a session. */ /** The client's address: the socket's, or what a trusted reverse proxy reports (console setting trustedProxies). */ String client(HttpExchange ex) { return Proxies.client(other.trustedProxies(), Http.client(ex), ex.getRequestHeaders().get("X-Forwarded-For")); } + /** The audit actor for a session: the person, and the agent key when the call came with one. */ static AuditLog.Actor who(Sessions.Session s) { - return new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()); + return new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via(), s.agent); } /** The session's directory: configured, or one the person entered at sign-in. */ @@ -630,7 +672,7 @@ On on(Sessions.Session s, String guid) throws javax.naming.NamingException { if (com.pointblue.console.server.ldap.Names.same(srv.dn(), own.dsaName())) { return new On(own.dsaName(), own, s.engine, s.engineProblem); } - if (!"oidc".equals(s.principal.via())) { + if (!s.principal.proxied()) { throw new Http.Problem(409, "server-sign-in-required", "sign in to " + srv.first("cn") + " to work with its replica", Map.of("server", Map.of("guid", guid, "dn", srv.dn(), "name", String.valueOf(srv.first("cn"))))); } @@ -809,6 +851,7 @@ private void oidcCallback(HttpExchange ex) throws IOException { : oidc.finish(d, config.secret(d.oidcSecretPath()), config.secret(d.serviceAccountPasswordPath()), q); if (o instanceof com.pointblue.console.server.auth.OidcLogin.SignedIn si) { failedByClient.clear(client); + agents.signedIn(si.principal()); SsoEngine engine = ssoEngine(d, si.connection(), d.target(), si.principal().dn(), null); Sessions.Session session = sessions.create(si.principal(), si.connection(), engine.engine(), engine.problem()); audit.log(new AuditLog.Actor(si.principal().subject(), si.principal().dn(), "oidc"), "sign-in", dirId, diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Settings.java b/console-server/src/main/java/com/pointblue/console/server/http/Settings.java index 28914e1..0730836 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Settings.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Settings.java @@ -38,6 +38,9 @@ * PUT /api/v2/directories/{id}/service-account {"dn", "password"} (checked by signing in as it), or null for none * GET /api/v2/directories/{id}/service-account/check its rights for sign-in and single sign-on, on a sample of people * PUT /api/v2/directories/{id}/session {"idleMinutes": 5-240, "maxHours": 1-24}: applies at once, to sessions already open too + * PUT /api/v2/directories/{id}/agent-keys {"enabled": bool, "maxDays": 1-90, "approveChanges": bool (true when absent)}: + * agent keys for MCP (see Agents); off stops every key here at once, and on + * again lets the unexpired ones work again * * * The tier, sign-in options and service account are what the console's first sign-in leaves for later @@ -92,6 +95,20 @@ void handle(HttpExchange ex, String path, String method) throws IOException { } }); } + case "PUT agent-keys" -> { + Map b = Json.asMap(body(ex)); + boolean enabled = Boolean.TRUE.equals(b.get("enabled")); + int maxDays = Http.integer(b, "maxDays", com.pointblue.console.server.auth.AgentKeys.MAX_DAYS); + boolean approve = !Boolean.FALSE.equals(b.get("approveChanges")); + save(ex, s, d, "agentKeys", Map.of("enabled", enabled, "maxDays", maxDays, "approveChanges", approve), v -> { + if (maxDays < 1 || maxDays > com.pointblue.console.server.auth.AgentKeys.MAX_DAYS) { + throw new IllegalArgumentException("the longest life of a key is 1 to " + com.pointblue.console.server.auth.AgentKeys.MAX_DAYS + " days"); + } + if (enabled && d.serviceAccountDn() == null) { + throw new IllegalArgumentException("agent keys need the directory's service account: set it first"); + } + }); + } case "PUT service-account" -> serviceAccount(ex, s, d); case "GET service-account/check" -> checkServiceAccount(ex, s, d); default -> throw new Http.Problem(404, "not-found", method + " " + path); @@ -122,6 +139,7 @@ private void settings(HttpExchange ex, DirectoryConfig d) throws IOException { m.put("serviceAccount", d.serviceAccountDn()); Sessions.Limits limits = server.sessionLimits(d.id()); m.put("session", Map.of("idleMinutes", limits == null ? 30 : limits.idle().toMinutes(), "maxHours", limits == null ? 12 : limits.absolute().toHours())); + m.put("agentKeys", server.agents.settings(d.id()).json()); m.put("login", Map.of("searchBases", d.searchBases(), "duplicates", d.duplicates() == DirectoryConfig.Duplicates.ERROR ? "error" : "choose", "allowed", d.directAllowed())); m.put("breakGlass", d.breakGlass()); @@ -169,7 +187,7 @@ private void save(HttpExchange ex, Sessions.Session s, DirectoryConfig d, String } catch (ConfigStore.Conflict c) { throw new Http.Problem(409, "version-conflict", c.getMessage()); } - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), "settings." + key, d.id(), + server.audit.log(Server.who(s), "settings." + key, d.id(), Map.of("summary", summary(key, value)), "ok"); settings(ex, DirectoryConfig.find(server.config.read(), d.id())); } @@ -433,7 +451,7 @@ private void checkServiceAccount(HttpExchange ex, Sessions.Session s, DirectoryC } catch (javax.naming.NamingException e) { throw new Http.Problem(502, "directory-error", Ldap.Failure.of(e).getMessage()); } - server.audit.log(new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()), "settings.service-account.check", d.id(), + server.audit.log(Server.who(s), "settings.service-account.check", d.id(), Map.of("result", out.get("result")), "ok"); Http.json(ex, 200, out); } diff --git a/console-server/src/main/java/com/pointblue/console/server/http/Trees.java b/console-server/src/main/java/com/pointblue/console/server/http/Trees.java index 7a9c8fe..9e05952 100644 --- a/console-server/src/main/java/com/pointblue/console/server/http/Trees.java +++ b/console-server/src/main/java/com/pointblue/console/server/http/Trees.java @@ -74,7 +74,7 @@ private List> list(Sessions.Session s) { } private static AuditLog.Actor actor(Sessions.Session s) { - return new AuditLog.Actor(s.principal.subject(), s.principal.dn(), s.principal.via()); + return Server.who(s); } private static String tier(Map b, String dflt) { @@ -193,6 +193,7 @@ private void remove(HttpExchange ex, Sessions.Session s, String id) throws IOExc throw new Http.Problem(409, "version-conflict", c.getMessage()); } int ended = server.sessions.removeDirectory(id); + server.agents.removeDirectory(id); int dropped = 0; for (TrustStore.Entry e : server.trust.entries()) { if (e.scope().equals("directory:" + id) && server.trust.remove(e.key())) { diff --git a/console-server/src/test/java/com/pointblue/console/server/auth/AgentKeysTest.java b/console-server/src/test/java/com/pointblue/console/server/auth/AgentKeysTest.java new file mode 100644 index 0000000..18fd2de --- /dev/null +++ b/console-server/src/test/java/com/pointblue/console/server/auth/AgentKeysTest.java @@ -0,0 +1,109 @@ +package com.pointblue.console.server.auth; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class AgentKeysTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** A clock the test moves. */ + private static final class Moving extends Clock { + Instant now = Instant.parse("2026-10-30T12:00:00Z"); + + @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId z) { return this; } + @Override public Instant instant() { return now; } + } + + private static Sessions.Principal pat(String via, List grants) { + return new Sessions.Principal("cn=pat,ou=users,o=acme", "cn=pat,ou=users,o=acme", "Pat", "acme", DirectLogin.roles(grants), via, grants); + } + + @Test + public void onlyTheHashIsKeptAndTheKeyIsFoundByItsToken() throws Exception { + Path file = tmp.getRoot().toPath().resolve("agents/keys.json"); + Moving clock = new Moving(); + AgentKeys keys = new AgentKeys(file, clock); + AgentKeys.Made m = keys.create("laptop", pat("direct", List.of(Grant.everywhere(Role.EDITOR))), Set.of(Role.VIEWER), Duration.ofDays(30)); + assertTrue(m.token().startsWith("dc_")); + assertEquals(46, m.token().length()); + String stored = Files.readString(file); + assertFalse("the token is never written", stored.contains(m.token().substring(3))); + assertTrue(stored.contains(AgentKeys.sha256(m.token()))); + assertFalse("the listing carries no hash", m.key().json(clock.now).containsKey("hash")); + + AgentKeys again = new AgentKeys(file, clock); + assertEquals("laptop", again.find(m.token()).label()); + assertNull(again.find("dc_nothing")); + assertNull(again.find(m.token().substring(3))); + assertEquals(m.key().id(), again.revoke(m.key().id()).id()); + assertNull(new AgentKeys(file, clock).find(m.token())); + } + + @Test + public void aKeyExpiresAndStopsWhenIdle() throws Exception { + Moving clock = new Moving(); + AgentKeys keys = new AgentKeys(tmp.getRoot().toPath().resolve("k.json"), clock); + AgentKeys.Key k = keys.create("ci", pat("direct", List.of(Grant.everywhere(Role.VIEWER))), Set.of(Role.VIEWER), Duration.ofDays(30)).key(); + assertEquals("active", k.state(clock.now)); + clock.now = clock.now.plus(Duration.ofDays(13)); + k = keys.used(k); + clock.now = clock.now.plus(Duration.ofDays(13)); + assertEquals("used 13 days ago", "active", k.state(clock.now)); + clock.now = clock.now.plus(Duration.ofDays(1)); + assertEquals("14 days unused", "idle", k.state(clock.now)); + clock.now = Instant.parse("2026-11-29T12:00:00Z"); + assertEquals("expired", k.state(clock.now)); + clock.now = clock.now.plus(Duration.ofDays(31)); + assertNull("a month after it expired the key is dropped", new AgentKeys(tmp.getRoot().toPath().resolve("k.json"), clock).byId(k.id())); + } + + @Test + public void whatAKeyMayDo() { + List grants = List.of(new Grant(Role.EDITOR, List.of("ou=sales,o=acme"), List.of("reset")), new Grant(null, List.of(), List.of("hr"))); + assertEquals("a role the key does not carry reads only, in the same scopes; presets are left out", + List.of(new Grant(Role.VIEWER, List.of("ou=sales,o=acme"), List.of())), AgentKeys.limit(grants, Set.of(Role.VIEWER))); + assertEquals(List.of(new Grant(Role.EDITOR, List.of("ou=sales,o=acme"), List.of())), AgentKeys.limit(grants, Set.of(Role.VIEWER, Role.EDITOR))); + assertEquals("admin becomes the key's roles, never admin", + List.of(Grant.everywhere(Role.VIEWER), Grant.everywhere(Role.OPERATOR)), + AgentKeys.limit(List.of(Grant.everywhere(Role.ADMIN)), Set.of(Role.VIEWER, Role.OPERATOR))); + assertTrue("presets alone give a key nothing", AgentKeys.limit(List.of(new Grant(null, List.of(), List.of("hr"))), Set.of(Role.VIEWER)).isEmpty()); + + Sessions.Principal editor = pat("direct", List.of(Grant.everywhere(Role.EDITOR))); + assertEquals(Set.of(Role.VIEWER), AgentKeys.roles(List.of(), editor)); + assertEquals(Set.of(Role.VIEWER, Role.EDITOR), AgentKeys.roles(List.of("editor"), editor)); + assertThrows(IllegalArgumentException.class, () -> AgentKeys.roles(List.of("operator"), editor)); + assertThrows(IllegalArgumentException.class, () -> AgentKeys.roles(List.of("admin"), pat("direct", List.of(Grant.everywhere(Role.ADMIN))))); + assertEquals(Set.of(Role.VIEWER, Role.OPERATOR), AgentKeys.roles(List.of("operator"), pat("direct", List.of(Grant.everywhere(Role.ADMIN))))); + } + + @Test + public void aSingleSignOnKeyTakesTheGrantsOfTheNextSignIn() throws Exception { + AgentKeys keys = new AgentKeys(tmp.getRoot().toPath().resolve("k.json"), new Moving()); + AgentKeys.Key k = keys.create("sso", pat("oidc", List.of(Grant.everywhere(Role.EDITOR))), Set.of(Role.VIEWER, Role.EDITOR), Duration.ofDays(14)).key(); + assertEquals(List.of(Grant.everywhere(Role.EDITOR)), k.grants()); + keys.signedIn(pat("oidc", List.of(Grant.everywhere(Role.VIEWER)))); + assertEquals(List.of(Grant.everywhere(Role.VIEWER)), keys.byId(k.id()).grants()); + AgentKeys.Key direct = keys.create("d", pat("direct", List.of(Grant.everywhere(Role.EDITOR))), Set.of(Role.VIEWER), Duration.ofDays(1)).key(); + assertNotNull(direct); + assertTrue("a direct sign-in's key keeps no grants: they are read on every call", direct.grants().isEmpty()); + } +} diff --git a/console-server/src/test/java/com/pointblue/console/server/http/AgentsTest.java b/console-server/src/test/java/com/pointblue/console/server/http/AgentsTest.java new file mode 100644 index 0000000..059e967 --- /dev/null +++ b/console-server/src/test/java/com/pointblue/console/server/http/AgentsTest.java @@ -0,0 +1,341 @@ +package com.pointblue.console.server.http; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.pointblue.console.server.auth.FakeIdp; +import com.pointblue.console.server.data.Backup; +import com.pointblue.console.server.data.DataDir; +import com.pointblue.console.server.idm.FakeEngine; +import com.pointblue.console.server.ldap.FakeEdir; +import com.pointblue.console.server.tls.Certs; +import com.pointblue.dirxml.dev.deploy.Vault; +import com.pointblue.dirxml.dev.json.Json; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Agent keys and the MCP server: a key made by a signed-in person, used over /mcp, acting with that person's rights. */ +public class AgentsTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private FakeEdir edir; + private FakeEngine engine; + private FakeIdp idp; + private Server server; + private HttpClient http; + private String base; + private Path dataPath; + + record Reply(int status, Map body, HttpResponse raw) { + } + + @Before + public void start() throws Exception { + edir = new FakeEdir( + "dn: cn=admin,o=system\nobjectClass: inetOrgPerson\ncn: admin\nsn: a\nuid: admin\nuserPassword: admin-pw", + "dn: cn=console,o=system\nobjectClass: inetOrgPerson\ncn: console\nsn: c\nuserPassword: console-pw", + "dn: ou=Sales,o=acme\nobjectClass: organizationalUnit\nou: Sales", + "dn: ou=Groups,o=acme\nobjectClass: organizationalUnit\nou: Groups", + "dn: cn=Editors,ou=Groups,o=acme\nobjectClass: groupOfNames\ncn: Editors\nmember: cn=ed,ou=Sales,o=acme", + "dn: cn=ed,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: ed\nsn: Editor\nfullName: Ed Editor\nuserPassword: ed-pw\ngroupMembership: cn=Editors,ou=Groups,o=acme", + "dn: cn=alice,ou=Sales,o=acme\nobjectClass: inetOrgPerson\ncn: alice\nsn: Ali\nuid: alice\ntitle: Rep", + "dn: cn=Driver Set,o=system\nobjectClass: DirXML-DriverSet\ncn: Driver Set", + "dn: cn=AD,cn=Driver Set,o=system\nobjectClass: DirXML-Driver\ncn: AD\nDirXML-TraceLevel: 3"); + engine = new FakeEngine(edir); + engine.states.put("cn=ad,cn=driverset,o=system", Vault.STATE_RUNNING); + idp = new FakeIdp().person("alice", Map.of("name", "Alice Ali", "preferred_username", "alice", "groups", List.of("sales-editors"))); + dataPath = tmp.newFolder("data").toPath(); + server = new Server(new DataDir(dataPath), ServerClient.loopback(), "https://localhost:8443", new PrintStream(new ByteArrayOutputStream())); + server.login.engines(engine.engines()); + server.start(); + base = "https://127.0.0.1:" + server.port(); + http = ServerClient.trusting(server); + Matcher m = Pattern.compile("Setup token: ([0-9a-f]{32})").matcher(Files.readString(dataPath.resolve("bootstrap.txt"))); + assertTrue(m.find()); + Map t = Map.of("X-Setup-Token", m.group(1)); + call("PUT", "/api/v2/setup/directory", Map.of("id", "acme", "name", "Acme", "host", "127.0.0.1", "port", edir.port(), "tier", "lab"), t); + Reply probe = call("POST", "/api/v2/setup/probe", Map.of(), t); + call("POST", "/api/v2/setup/trust", Map.of("probeId", probe.body().get("probeId"), "confirm", Certs.tail(edir.pki.ca, 8)), t); + call("POST", "/api/v2/setup/authority", Map.of("dn", "cn=admin,o=system", "password", "admin-pw"), t); + call("PUT", "/api/v2/setup/service-account", Map.of("dn", "cn=console,o=system", "password", "console-pw"), t); + assertEquals(200, call("POST", "/api/v2/setup/finish", Map.of(), t).status()); + server.config.update(server.config.version(), "test", "agents", doc -> { + Map d = Json.asMap(Json.asMap(doc.get("directories")).get("acme")); + d.put("roles", List.of(Map.of("group", "cn=Editors,ou=Groups,o=acme", "role", "editor", "scopes", List.of("ou=Sales,o=acme")))); + d.put("oidc", Map.of("issuer", idp.issuer, "clientId", "console", "label", "Acme SSO", "claim", "preferred_username", + "attribute", "uid", "searchBases", List.of("o=acme"), + "roles", List.of(Map.of("group", "sales-editors", "role", "editor", "scopes", List.of("ou=Sales,o=acme"))))); + }); + } + + @After + public void stop() { + server.stop(); + edir.close(); + idp.close(); + } + + private Reply call(String method, String path, Object body, Map headers) throws Exception { + HttpRequest.Builder b = ServerClient.request(URI.create(path.startsWith("http") ? path : base + path)) + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.compact(body))); + if (body != null) { + b.header("Content-Type", "application/json"); + } + headers.forEach(b::header); + HttpResponse r = http.send(b.build(), HttpResponse.BodyHandlers.ofString()); + Object parsed = r.body().isBlank() ? Map.of() : r.body().startsWith("[") || r.body().startsWith("{") ? Json.parse(r.body()) : Map.of(); + return new Reply(r.statusCode(), parsed instanceof Map ? Json.asMap(parsed) : Map.of("list", parsed), r); + } + + private Map signIn(String dn, String pw) throws Exception { + Reply r = call("POST", "/api/v2/session", Map.of("directory", "acme", "name", dn, "password", pw), Map.of()); + assertEquals(String.valueOf(r.body()), 200, r.status()); + return withCsrf(cookie(r)); + } + + private static Map cookie(Reply r) { + String c = r.raw().headers().allValues("Set-Cookie").stream().filter(x -> x.startsWith(Server.COOKIE + "=")).findFirst().orElseThrow(); + Map h = new HashMap<>(); + h.put("Cookie", c.substring(0, c.indexOf(';'))); + return h; + } + + private Map withCsrf(Map h) throws Exception { + Map out = new HashMap<>(h); + out.put("X-CSRF-Token", String.valueOf(call("GET", "/api/v2/session", null, h).body().get("csrf"))); + return out; + } + + private void agentKeys(boolean enabled) throws Exception { + Map admin = signIn("cn=admin,o=system", "admin-pw"); + Reply r = call("PUT", "/api/v2/directories/acme/agent-keys", Map.of("enabled", enabled, "maxDays", 60), admin); + assertEquals(String.valueOf(r.body()), 200, r.status()); + Map a = Json.asMap(r.body().get("agentKeys")); + assertEquals(enabled, a.get("enabled")); + assertEquals(60, Json.asInt(a.get("maxDays"), 0)); + assertEquals(true, a.get("approveChanges")); + } + + private String makeKey(Map h, Map body) throws Exception { + Reply r = call("POST", "/api/v2/agent-keys", body, h); + assertEquals(String.valueOf(r.body()), 201, r.status()); + assertEquals("https://localhost:8443/mcp", r.body().get("mcpUrl")); + return String.valueOf(r.body().get("token")); + } + + private int rpcId; + + /** One JSON-RPC call to /mcp with {@code key}. */ + private Reply mcp(String key, String method, Map params) throws Exception { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", ++rpcId); + m.put("method", method); + m.put("params", params); + return call("POST", "/mcp", m, key == null ? Map.of() : Map.of("Authorization", "Bearer " + key)); + } + + /** A tool's result: its text parsed, and whether it is an error. */ + private record Result(boolean error, Map body) { + } + + private Result tool(String key, String name, Map args) throws Exception { + Reply r = mcp(key, "tools/call", Map.of("name", name, "arguments", args)); + assertEquals(String.valueOf(r.body()), 200, r.status()); + Map result = Json.asMap(r.body().get("result")); + String text = String.valueOf(Json.asMap(Json.asList(result.get("content")).get(0)).get("text")); + Object o = Json.parse(text); + return new Result(Boolean.TRUE.equals(result.get("isError")), o instanceof Map ? Json.asMap(o) : Map.of("items", o)); + } + + private String audit() throws Exception { + StringBuilder b = new StringBuilder(); + try (var files = Files.list(dataPath.resolve("audit"))) { + for (Path p : files.toList()) { + b.append(Files.readString(p)); + } + } + return b.toString(); + } + + @Test + public void keysAreOffUntilAnAdminTurnsThemOn() throws Exception { + Map ed = signIn("cn=ed,ou=Sales,o=acme", "ed-pw"); + Reply r = call("POST", "/api/v2/agent-keys", Map.of("label", "laptop", "password", "ed-pw"), ed); + assertEquals(409, r.status()); + assertEquals("agent-keys-off", r.body().get("title")); + Map list = call("GET", "/api/v2/agent-keys", null, ed).body(); + assertEquals(false, Json.asMap(list.get("settings")).get("enabled")); + assertEquals("approval is required by default", true, Json.asMap(list.get("settings")).get("approveChanges")); + } + + @Test + public void aKeyReadsAsThePersonOverMcp() throws Exception { + agentKeys(true); + Map ed = signIn("cn=ed,ou=Sales,o=acme", "ed-pw"); + Reply wrong = call("POST", "/api/v2/agent-keys", Map.of("label", "laptop", "password", "nope"), ed); + assertEquals("the password proves the person is here", 401, wrong.status()); + String key = makeKey(ed, Map.of("label", "laptop", "password", "ed-pw", "roles", List.of("editor"))); + assertTrue(key.startsWith("dc_")); + List mine = Json.asList(call("GET", "/api/v2/agent-keys", null, ed).body().get("keys")); + assertEquals(1, mine.size()); + assertEquals(List.of("editor", "viewer"), Json.asMap(mine.get(0)).get("roles")); + assertFalse(String.valueOf(mine).contains(key)); + + Reply init = mcp(key, "initialize", Map.of("protocolVersion", "2025-06-18")); + assertEquals(200, init.status()); + assertEquals("2025-06-18", Json.asMap(init.body().get("result")).get("protocolVersion")); + List tools = Json.asList(Json.asMap(mcp(key, "tools/list", Map.of()).body().get("result")).get("tools")); + assertTrue(tools.size() >= 20); + assertTrue("every tool of this version reads", tools.stream().allMatch(t -> Json.asMap(Json.asMap(t).get("annotations")).get("readOnlyHint").equals(true))); + + Result who = tool(key, "whoami", Map.of()); + assertFalse(who.error()); + assertEquals("cn=ed,ou=Sales,o=acme", who.body().get("dn")); + assertEquals("agent", who.body().get("via")); + assertEquals("Ed Editor", who.body().get("name")); + assertFalse("no CSRF token for an agent", who.body().containsKey("csrf")); + assertEquals("laptop", Json.asMap(who.body().get("key")).get("label")); + + edir.lastProxiedSearch = null; + Result alice = tool(key, "read_object", Map.of("object", "cn=alice,ou=Sales,o=acme")); + assertFalse(String.valueOf(alice.body()), alice.error()); + assertEquals("cn=alice,ou=Sales,o=acme", alice.body().get("dn")); + assertEquals("it acts as the person, on the service account's connection", "dn:cn=ed,ou=Sales,o=acme", edir.lastProxiedSearch); + Result byGuid = tool(key, "read_object", Map.of("object", alice.body().get("guid"))); + assertEquals(alice.body().get("dn"), byGuid.body().get("dn")); + + Result found = tool(key, "search_objects", Map.of("filter", "(sn=Ali)", "base", "o=acme")); + assertFalse(String.valueOf(found.body()), found.error()); + assertEquals(1, Json.asList(found.body().get("items")).size()); + + Result missing = tool(key, "read_object", Map.of("object", "cn=nobody,ou=Sales,o=acme")); + assertTrue(missing.error()); + assertEquals("no-such-object", missing.body().get("title")); + Result noArg = tool(key, "read_object", Map.of()); + assertTrue(noArg.error()); + + Result set = tool(key, "driver_set", Map.of("driver_set", "cn=Driver Set,o=system")); + assertFalse(String.valueOf(set.body()), set.error()); + assertTrue(engine.actions.contains("act-as cn=ed,ou=Sales,o=acme")); + assertTrue(audit().contains("\"agent-key.create\"")); + } + + @Test + public void refusals() throws Exception { + agentKeys(true); + Map ed = signIn("cn=ed,ou=Sales,o=acme", "ed-pw"); + String key = makeKey(ed, Map.of("label", "laptop", "password", "ed-pw")); + + Reply none = mcp(null, "tools/list", Map.of()); + assertEquals(401, none.status()); + assertTrue(none.raw().headers().firstValue("WWW-Authenticate").orElse("").startsWith("Bearer")); + assertEquals(401, mcp("dc_" + "x".repeat(43), "tools/list", Map.of()).status()); + assertEquals(405, call("GET", "/mcp", null, Map.of("Authorization", "Bearer " + key)).status()); + Reply rest = call("GET", "/api/v2/session", null, Map.of("Authorization", "Bearer " + key)); + assertEquals("a key works at /mcp only, not on the API directly", 401, rest.status()); + + // the account locked by intruder detection: eDirectory would still accept the control, the console does not + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "lockedByIntruder", "TRUE"), + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "loginIntruderResetTime", "20991231000000Z"))); + Reply locked = mcp(key, "tools/list", Map.of()); + assertEquals(403, locked.status()); + assertEquals("locked", locked.body().get("title")); + assertTrue(audit().contains("\"agent-key.use\"")); + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "lockedByIntruder", "FALSE"))); + assertEquals(200, mcp(key, "tools/list", Map.of()).status()); + + // a role taken away reaches the key at its next call + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.DELETE, "groupMembership"))); + Reply noRole = mcp(key, "tools/list", Map.of()); + assertEquals(403, noRole.status()); + assertEquals("no-role", noRole.body().get("title")); + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.ADD, "groupMembership", "cn=Editors,ou=Groups,o=acme"))); + + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.REPLACE, "loginDisabled", "TRUE"))); + assertEquals("account-disabled", mcp(key, "tools/list", Map.of()).body().get("title")); + edir.server.modify("cn=ed,ou=Sales,o=acme", List.of( + new com.unboundid.ldap.sdk.Modification(com.unboundid.ldap.sdk.ModificationType.DELETE, "loginDisabled"))); + + agentKeys(false); + assertEquals("agent-keys-off", mcp(key, "tools/list", Map.of()).body().get("title")); + agentKeys(true); + assertEquals("on again, the key works again", 200, mcp(key, "tools/list", Map.of()).status()); + + String id = String.valueOf(Json.asMap(Json.asList(call("GET", "/api/v2/agent-keys", null, ed).body().get("keys")).get(0)).get("id")); + assertEquals(204, call("DELETE", "/api/v2/agent-keys/" + id, null, ed).status()); + assertEquals("a revoked key stops at once", 401, mcp(key, "tools/list", Map.of()).status()); + assertTrue(audit().contains("\"agent-key.revoke\"")); + } + + @Test + public void whatAKeyMayCarry() throws Exception { + agentKeys(true); + Map ed = signIn("cn=ed,ou=Sales,o=acme", "ed-pw"); + Reply op = call("POST", "/api/v2/agent-keys", Map.of("label", "x", "password", "ed-pw", "roles", List.of("operator")), ed); + assertEquals("not a role the person has", 400, op.status()); + Reply admin = call("POST", "/api/v2/agent-keys", Map.of("label", "x", "password", "ed-pw", "roles", List.of("admin")), ed); + assertEquals(400, admin.status()); + Reply long_ = call("POST", "/api/v2/agent-keys", Map.of("label", "x", "password", "ed-pw", "days", 61), ed); + assertEquals("longer than the directory allows", 400, long_.status()); + + Map root = signIn("cn=admin,o=system", "admin-pw"); + Reply breakGlass = call("POST", "/api/v2/agent-keys", Map.of("label", "x", "password", "admin-pw"), root); + assertEquals("a break-glass sign-in makes no keys", 409, breakGlass.status()); + assertEquals("an admin lists everyone's keys", 200, call("GET", "/api/v2/agent-keys?all=true", null, root).status()); + assertEquals(403, call("GET", "/api/v2/agent-keys?all=true", null, ed).status()); + } + + @Test + public void aSingleSignOnPersonGetsShorterKeys() throws Exception { + agentKeys(true); + idp.signInAs = "alice"; + Reply start = call("GET", "/api/v2/oidc/start?directory=acme&next=%2F", null, Map.of()); + String state = start.raw().headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(Server.OIDC_COOKIE + "=")).findFirst().orElseThrow(); + Reply atIdp = call("GET", start.raw().headers().firstValue("Location").orElseThrow(), null, Map.of()); + String back = atIdp.raw().headers().firstValue("Location").orElseThrow(); + Reply cb = call("GET", base + back.substring("https://localhost:8443".length()), null, Map.of("Cookie", state.substring(0, state.indexOf(';')))); + Map alice = withCsrf(cookie(cb)); + assertEquals(14, Json.asInt(Json.asMap(call("GET", "/api/v2/agent-keys", null, alice).body().get("settings")).get("longestDays"), 0)); + assertEquals(400, call("POST", "/api/v2/agent-keys", Map.of("label", "x", "days", 15), alice).status()); + String key = makeKey(alice, Map.of("label", "sso laptop", "days", 14)); + Result who = tool(key, "whoami", Map.of()); + assertEquals("cn=alice,ou=Sales,o=acme", who.body().get("dn")); + assertNotNull(who.body().get("roles")); + } + + @Test + public void keysAreNotInBackups() throws Exception { + agentKeys(true); + makeKey(signIn("cn=ed,ou=Sales,o=acme", "ed-pw"), Map.of("label", "laptop", "password", "ed-pw")); + assertTrue(Files.exists(dataPath.resolve("agents/keys.json"))); + byte[] b = Backup.create(new DataDir(dataPath), "a long passphrase".toCharArray(), true, "test"); + assertTrue(Backup.open(b, "a long passphrase".toCharArray()).keySet().stream().noneMatch(f -> f.startsWith("agents"))); + } +} diff --git a/console-server/src/test/java/com/pointblue/console/server/ldap/FakeEdir.java b/console-server/src/test/java/com/pointblue/console/server/ldap/FakeEdir.java index e75b324..8338f18 100644 --- a/console-server/src/test/java/com/pointblue/console/server/ldap/FakeEdir.java +++ b/console-server/src/test/java/com/pointblue/console/server/ldap/FakeEdir.java @@ -50,6 +50,8 @@ public final class FakeEdir implements AutoCloseable { public volatile int privileges = 0x1f; /** The authorization id of the last modify's RFC 4370 control ("dn:…"), or null when it had none. */ public volatile String lastProxied; + /** The proxied-authorization control of the last search that carried one. */ + public volatile String lastProxiedSearch; /** Partitions and replicas (empty until a test or the demo adds some). */ public final FakePartitions partitions = new FakePartitions(); /** PKI extended operations: server certificates as key material objects, user certificates in memory. */ @@ -93,6 +95,14 @@ public void processAddRequest(InMemoryInterceptedAddRequest request) { request.setRequest(a); } + @Override + public void processSearchRequest(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchRequest request) { + com.unboundid.ldap.sdk.Control proxied = request.getRequest().getControl("2.16.840.1.113730.3.4.18"); + if (proxied != null && proxied.getValue() != null) { + lastProxiedSearch = proxied.getValue().stringValue(); + } + } + @Override public void processModifyRequest(InMemoryInterceptedModifyRequest request) { ReadOnlyModifyRequest r = request.getRequest(); diff --git a/console-web/src/api.ts b/console-web/src/api.ts index cc863c6..0db428b 100644 --- a/console-web/src/api.ts +++ b/console-web/src/api.ts @@ -873,6 +873,7 @@ export interface DirectorySettings { /** {@code allowed}: who may still sign in with a password while direct sign-in is off (besides break-glass). */ login: { searchBases: string[]; duplicates: 'choose' | 'error'; allowed: string[] }; session: { idleMinutes: number; maxHours: number }; + agentKeys: AgentKeySettings; breakGlass: string[]; roles: RoleMappingJson[]; presets: Preset[]; @@ -900,9 +901,48 @@ export function settings(id: string) { checkServiceAccount: () => request('GET', `${base}/service-account/check`).then((r) => r.data), /** Idle timeout and longest session for this tree; applies at once, to open sessions too. */ sessionLimits: (limits: { idleMinutes: number; maxHours: number }, etag?: string) => put('session', limits, etag), + /** Agent keys for MCP: on or off, their longest life, and whether agents' changes wait for approval. */ + agentKeys: (a: AgentKeySettings, etag?: string) => put('agent-keys', a, etag), }; } +// ---- agent keys (MCP) ------------------------------------------------------------------ + +/** A directory's agent keys: off by default; changes an agent asks for wait for approval unless turned off. */ +export interface AgentKeySettings { + enabled: boolean; + maxDays: number; + approveChanges: boolean; +} + +export interface AgentKey { + id: string; + label: string; + directory: string; + dn: string; + name: string; + via: 'direct' | 'oidc'; + roles: RoleId[]; + created: string; + expires: string; + lastUsed: string | null; + state: 'active' | 'expired' | 'idle'; +} + +export interface AgentKeyList { + keys: AgentKey[]; + settings: AgentKeySettings & { serviceAccount: boolean; longestDays: number }; + mcpUrl: string; +} + +/** The signed-in person's agent keys; an admin may list everyone's. A key is shown once, when it is made. */ +export const agentKeys = { + list: (all = false) => request('GET', `/api/v2/agent-keys${all ? '?all=true' : ''}`).then((r) => r.data), + create: (body: { label: string; days: number; roles: RoleId[]; password?: string }) => + request<{ token: string; key: AgentKey; mcpUrl: string }>('POST', '/api/v2/agent-keys', body).then((r) => r.data), + revoke: (id: string) => request('DELETE', `/api/v2/agent-keys/${encodeURIComponent(id)}`), +}; + // ---- eDirectory administration ------------------------------------------------------- export type ReplicaType = 'master' | 'read-write' | 'read-only' | 'subordinate-reference' | 'sparse-write' | 'sparse-read'; diff --git a/console-web/src/components/Shell.tsx b/console-web/src/components/Shell.tsx index 25c0269..9e10b5f 100644 --- a/console-web/src/components/Shell.tsx +++ b/console-web/src/components/Shell.tsx @@ -30,6 +30,7 @@ const ServerPage = lazy(() => import('../pages/EdirPage').then((m) => ({ default const SecurityPage = lazy(() => import('../pages/SecurityPage').then((m) => ({ default: m.SecurityPage }))); const PasswordPolicyPage = lazy(() => import('../pages/PolicyPages').then((m) => ({ default: m.PasswordPolicyPage }))); const AuditPage = lazy(() => import('../pages/AuditPage').then((m) => ({ default: m.AuditPage }))); +const AgentKeysPage = lazy(() => import('../pages/AgentKeysPage').then((m) => ({ default: m.AgentKeysPage }))); const ChallengeSetPage = lazy(() => import('../pages/PolicyPages').then((m) => ({ default: m.ChallengeSetPage }))); const NAV: { to: string; label: string; show?: (s: ReturnType) => boolean }[] = [ @@ -116,6 +117,9 @@ export function Shell() { Directory Console {version.version} )} + {(me.via === 'direct' || me.via === 'oidc') && ( + { setUserOpen(false); navigate('/agent-keys'); }}>Agent keys + )} Sign out @@ -235,6 +239,9 @@ function route(path: string) { if (path === '/audit') { return ; } + if (path === '/agent-keys') { + return ; + } if (path === '/certificates') { return null; // on its way to Settings > Trusted certificates } diff --git a/console-web/src/help/content.ts b/console-web/src/help/content.ts index 48e905c..6a61e66 100644 --- a/console-web/src/help/content.ts +++ b/console-web/src/help/content.ts @@ -826,6 +826,15 @@ export const CONTENT: Record = { ], }, { title: 'Sessions', paragraphs: ['Idle sign-out and the longest session apply to everyone on this directory, at once. People are warned two minutes before an idle sign-out, and told when the longest session will end.'] }, + { + title: 'Agent keys', + list: [ + 'Off by default. On, people make keys for their AI agents under their name > Agent keys; it needs the service account.', + 'The longest life applies to new keys: 1 to 90 days. Keys made with single sign-on live at most 14 days, and any key unused for 14 days stops.', + 'Turning keys off stops every key on this directory at once; turning them on again lets the unexpired ones work again.', + 'Approval of changes is on by default: a change an agent asks for waits until the person approves it in the console. The agent tools of this version only read, so nothing waits yet.', + ], + }, { title: 'Service account', paragraphs: [ @@ -843,6 +852,30 @@ export const CONTENT: Record = { }, ], }, + 'agent-keys': { + title: 'Agent keys', + summary: 'Keys that let an AI agent use this console through its MCP server, as you, with your own eDirectory rights.', + sections: [ + { + title: 'Making a key', + list: [ + 'Give it a label that names the agent or machine: the audit log names the key by it.', + 'Choose how long it lasts, and what it may do: reading always, and changing objects or running Identity Manager only if you have those roles. A key never has the admin role.', + 'Type your password to confirm it is you (with single sign-on: a sign-in within the last 10 minutes).', + 'The key is shown once. Copy it, with the command or settings for your agent, and keep it like a password.', + ], + }, + { + title: 'What the agent can do', + paragraphs: [ + 'Everything the agent does runs as you, with your eDirectory rights and at most the key\'s roles. If your account is disabled, expires or is locked by intruder detection, or you lose your role, the key stops working at its next call.', + 'The agent tools of this version only read: objects, groups, sign-in status, drivers, jobs, associations, caches, statistics and versions.', + ], + }, + { title: 'Revoking a key', paragraphs: ['Revoke stops a key at once. Administrators can see and revoke everyone\'s keys here.'] }, + ], + related: ['settings.directory'], + }, 'settings.roles': { title: 'Role mappings', summary: 'Which eDirectory groups get which console role.', diff --git a/console-web/src/help/topics.test.ts b/console-web/src/help/topics.test.ts index 4da1427..6de9186 100644 --- a/console-web/src/help/topics.test.ts +++ b/console-web/src/help/topics.test.ts @@ -3,7 +3,7 @@ import { TOPICS, topicFor, topicForPath } from './topics'; /** Every page and tab of the console, as the shell and the tabbed pages name them. */ const PAGES = [ - 'browse', 'object', 'search', 'schema', 'settings', 'audit', + 'browse', 'object', 'search', 'schema', 'settings', 'audit', 'agent-keys', 'edir.partitions', 'edir.servers', 'edir.server', 'edir.ldap', 'edir.ldap-server', 'edir.ldap-group', 'edir.audit', 'edir.snmp', 'edir.encryption', 'security.certificates', 'security.password-policies', 'security.challenge-sets', 'security.login-methods', 'security.eba', 'security.password-policy', 'security.challenge-set', diff --git a/console-web/src/help/topics.ts b/console-web/src/help/topics.ts index c42d56b..c9b4ad1 100644 --- a/console-web/src/help/topics.ts +++ b/console-web/src/help/topics.ts @@ -83,6 +83,9 @@ export function topicForPath(path: string): string { if (p.startsWith('/edir/servers/')) { return 'edir.server'; } + if (p === '/agent-keys') { + return 'agent-keys'; + } if (p.startsWith('/idm/drivers/')) { return 'idm.driver'; } diff --git a/console-web/src/pages/AgentKeysPage.tsx b/console-web/src/pages/AgentKeysPage.tsx new file mode 100644 index 0000000..ca8c436 --- /dev/null +++ b/console-web/src/pages/AgentKeysPage.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, Button, Checkbox, ClipboardCopy, ClipboardCopyVariant, EmptyState, EmptyStateBody, Form, FormGroup, FormHelperText, HelperText, HelperTextItem, + Label, Modal, ModalBody, ModalFooter, ModalHeader, PageSection, Spinner, Switch, TextInput, Title, Toolbar, ToolbarContent, ToolbarItem, +} from '@patternfly/react-core'; +import { PlusIcon } from '@patternfly/react-icons'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { type AgentKey, type AgentKeyList, type RoleId, ApiError, agentKeys } from '../api'; +import { useSession } from '../session'; +import { ProblemAlert } from '../components/Problem'; +import { useStepUp } from '../components/StepUp'; +import { useAreYouSure } from '../components/Confirm'; +import { useHelpTopic } from '../help/Help'; + +const STATE_COLOR = { active: 'green', idle: 'orange', expired: 'grey' } as const; +const when = (t: string | null) => (t ? new Date(t).toLocaleString() : 'never'); + +/** + * Agent keys: a person makes one for their AI agent, which then uses the console's MCP server as them, with their own + * rights. The key is shown once. Admins may also see and revoke everyone's keys in this directory. + */ +export function AgentKeysPage() { + useHelpTopic('agent-keys'); + const session = useSession(); + const [all, setAll] = useState(false); + const [list, setList] = useState(null); + const [error, setError] = useState(null); + const [making, setMaking] = useState(false); + const [made, setMade] = useState<{ token: string; key: AgentKey; mcpUrl: string } | null>(null); + const sure = useAreYouSure(); + const load = useCallback(() => { + setError(null); + agentKeys.list(all).then(setList, setError); + }, [all]); + useEffect(load, [load]); + const revoke = (k: AgentKey) => sure.ask(`Revoke ${k.label}?`, 'The agent using this key loses access at once. This cannot be undone; a new key can be made.', 'Revoke', + () => agentKeys.revoke(k.id).then(load, setError)); + + return ( + + Agent keys +

An agent key lets an AI agent work in this console as you, through its MCP server, with your own eDirectory rights and + at most the roles you give the key. The agent can never do more than you can. This version's agent tools only read.

+ + {!list && !error && } + {list && !list.settings.enabled && ( + + An administrator turns them on in Settings > Directory.{list.keys.length > 0 && ' Keys made earlier work again when they are turned back on, until they expire.'} + + )} + {list && ( + <> + + + {list.settings.enabled && ( + + + + )} + {session.can('admin') && ( + + setAll(v)} /> + + )} + + + {list.keys.length === 0 ? ( +

{all ? 'Nobody has an agent key in this directory.' : 'You have no agent keys here.'}

+ ) : ( + + + + + {all && } + + + + + + + + + {list.keys.map((k) => ( + + + {all && } + + + + + + + + ))} + +
LabelPersonRolesStateMadeExpiresLast used +
{k.label} {k.id}{k.dn}{k.roles.join(', ')}{when(k.created)}{when(k.expires)}{when(k.lastUsed)}
+ )} + + )} + {making && list && ( + setMaking(false)} onMade={(m) => { setMaking(false); setMade(m); load(); }} /> + )} + {made && setMade(null)} />} + {sure.dialog} +
+ ); +} + +const ROLE_TEXT: Record<'editor' | 'operator', string> = { + editor: 'Change directory objects (editor)', + operator: 'Run Identity Manager operations (operator)', +}; + +function NewKey({ list, onCancel, onMade }: { list: AgentKeyList; onCancel: () => void; onMade: (m: { token: string; key: AgentKey; mcpUrl: string }) => void }) { + const { me, can } = useSession(); + const { run, dialog } = useStepUp(); + const sso = me.via === 'oidc'; + const longest = list.settings.longestDays; + const [label, setLabel] = useState(''); + const [days, setDays] = useState(String(Math.min(30, longest))); + const [roles, setRoles] = useState([]); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const daysOk = /^\d+$/.test(days) && Number(days) >= 1 && Number(days) <= longest; + const offered = (['editor', 'operator'] as const).filter((r) => can(r)); + const submit = (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + run(() => agentKeys.create({ label: label.trim(), days: Number(days), roles: ['viewer', ...roles], password: sso ? undefined : password })) + .then(onMade, (x) => { setError(x instanceof ApiError && x.status === 401 ? new Error('The password is incorrect.') : x); setPassword(''); }) + .finally(() => setBusy(false)); + }; + return ( + + + +
+ + setLabel(v)} placeholder="Claude Code on my laptop" isRequired autoFocus /> + Which agent or machine uses it. The audit log names the key by this label. + + + setDays(v)} validated={daysOk ? 'default' : 'error'} /> + + 1 to {longest} days{sso ? ' (keys made with single sign-on live at most 14 days)' : ''}. A key unused for 14 days also stops. + + + + + {offered.map((r) => ( + setRoles(v ? [...roles, r] : roles.filter((x) => x !== r))} /> + ))} + Never more than your own roles, and never admin. The agent tools of this version only read. + + {!sso && ( + + setPassword(v)} isRequired /> + To confirm it is you. It is checked and not kept. + + )} + {sso &&

Making a key needs a single sign-on sign-in within the last 10 minutes.

} +
+ +
+ + + + + {dialog} +
+ ); +} + +function ShowKey({ made, onClose }: { made: { token: string; key: AgentKey; mcpUrl: string }; onClose: () => void }) { + const claude = `claude mcp add --transport http directory-console ${made.mcpUrl} --header "Authorization: Bearer ${made.token}"`; + const json = JSON.stringify({ mcpServers: { 'directory-console': { type: 'http', url: made.mcpUrl, headers: { Authorization: `Bearer ${made.token}` } } } }, null, 2); + return ( + + + + + Keep it like a password. Anyone with it can act as you, within the key's roles, until it expires or you revoke it. + +
+ + {made.token} + + + {made.mcpUrl} + + + {claude} + + + {json} + The agent's machine must trust the console's certificate. + +
+
+ + + +
+ ); +} diff --git a/console-web/src/pages/SettingsPage.tsx b/console-web/src/pages/SettingsPage.tsx index d9fb0a2..99e7349 100644 --- a/console-web/src/pages/SettingsPage.tsx +++ b/console-web/src/pages/SettingsPage.tsx @@ -119,6 +119,9 @@ function DirectorySection({ s, api, etag, onSaved }: { const [saDn, setSaDn] = useState(s.serviceAccount ?? ''); const [idleMinutes, setIdleMinutes] = useState(String(s.session.idleMinutes)); const [maxHours, setMaxHours] = useState(String(s.session.maxHours)); + const [agentOn, setAgentOn] = useState(s.agentKeys.enabled); + const [agentDays, setAgentDays] = useState(String(s.agentKeys.maxDays)); + const [agentApprove, setAgentApprove] = useState(s.agentKeys.approveChanges); const [saPassword, setSaPassword] = useState(''); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -160,6 +163,30 @@ function DirectorySection({ s, api, etag, onSaved }: { onClick={() => go('The session limits are saved.', () => api.sessionLimits({ idleMinutes: Number(idleMinutes), maxHours: Number(maxHours) }, etag))}>Save the session limits + +

+ People make agent keys under their name > Agent keys, so an AI agent can use this console's MCP server as them, with their own rights. + Keys need the service account. Turning them off stops every key here at once. +

+ + setAgentOn(v)} isDisabled={!s.serviceAccount && !agentOn} /> + + + setAgentDays(v)} + validated={sessionOk(agentDays, 1, 90) ? 'default' : 'error'} /> + 1 to 90 days; 90 by default. Keys made with single sign-on live at most 14 days. + + + setAgentApprove(v)} + description="On by default. Off, an agent's changes go ahead within its key's roles. The agent tools of this version make no changes yet." /> + + + + +

{s.serviceAccount ? `The console uses ${s.serviceAccount}.` : 'None yet: people sign in with their full name (DN), and single sign-on is unavailable.'} diff --git a/docs/API.md b/docs/API.md index c605db0..fff8c9e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -153,6 +153,21 @@ The check is NMAS's Password Policy Check (`2.16.840.1.113719.1.39.42.100.17`): `code` is NMAS's or eDirectory's (0 when it passes; -216 too short, -16000 too long, -16057 digits or special characters the policy does not allow, and so on). +## Agent keys and MCP, new in 0.4.0 + +``` +GET /api/v2/agent-keys your keys, the directory's agent key settings and the MCP address; ?all=true: everyone's (admin) +POST /api/v2/agent-keys {"label", "days"?, "roles"? ["viewer", "editor", "operator"], "password" (direct sign-in)}: {token, key, mcpUrl} +DELETE /api/v2/agent-keys/{id} your own key, or anyone's for an admin +PUT /api/v2/directories/{id}/agent-keys {"enabled", "maxDays" (1-90), "approveChanges" (true when absent)} (admin, recent sign-in) +POST /mcp JSON-RPC 2.0 (MCP, Streamable HTTP, stateless) with Authorization: Bearer dc_… +``` + +The token is shown once; the console keeps its SHA-256. With single sign-on, making a key needs a sign-in within the +last 10 minutes instead of the password, and the key lives at most 14 days. A key works only at `/mcp`; refusals there +are problem documents (`agent-key-required`, `agent-key-invalid`, `agent-key-expired`, `agent-key-idle`, +`agent-keys-off`, `locked`, `account-disabled`, `account-expired`, `no-role`). See [MCP.md](MCP.md). + ## Console settings ``` diff --git a/docs/MCP.md b/docs/MCP.md index c1e80cd..464b608 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,6 +1,8 @@ # Agents over MCP (design) -**Status:** design note, decided 2026-10-30. Nothing here is built yet. The decisions are at the end. +**Status:** decided 2026-10-30. Phase 1 is built: agent keys, the `/mcp` endpoint and the read tools (see +[Using it](#using-it)). Phases 2 (changes) and 3 (Identity Applications) are not built yet. The decisions are at the +end. This note proposes letting a person's AI agent do day-to-day administration through the console, over the Model Context Protocol (MCP). Examples are looking up an account, finding out why a user did not synchronize, starting a @@ -90,8 +92,10 @@ The console keeps only a SHA-256 of the key, with: `ServiceAccountCheck` already checks this for single sign-on. Without a service account, keys are off for that directory. - **Roles:** what a key may do is its role ceiling intersected with the person's current grants. - - **Direct sign-in:** grants come from eDirectory group membership, so the console re-reads them on use, cached - for five minutes. A role taken away reaches the person's keys within minutes. + - **Direct sign-in:** grants come from eDirectory group membership. The console reads them on every call, in the + same read as the intruder lockout, so a role taken away reaches the person's keys at their next call. + - **A role the key does not carry:** the grant still reads, with the same container scopes; admin becomes each + of the key's roles. Task presets never reach a key: they are changes. - **Single sign-on:** grants come from the provider's groups, which the console cannot read without a sign-in. It keeps the grants from the person's last sign-in. Their next sign-in with fewer roles narrows their keys at once. Such keys get a shorter longest life. @@ -111,9 +115,11 @@ The console keeps only a SHA-256 of the key, with: - **Audit:** every entry made with a key names the person and the key (its label and the first characters of its id), with `via` set to `agent`. -## Production directories +## Approving agents' changes -On a directory marked production, writes from a key need a person's approval: +Each directory has a setting, *Changes an agent asks for wait for the person's approval*, on by default (changed +2026-10-30: it was going to apply to production directories only, always). While it is on, writes from a key need a +person's approval: 1. The tool records the change as pending: what, on which object, and with which key. It answers with the pending change's id and a link. @@ -123,6 +129,9 @@ On a directory marked production, writes from a key need a person's approval: Pending changes expire after an hour. Reads need no approval. +With the setting off, a key's writes go ahead within its roles, and are audited as the agent's. On a production +directory they still need what any change there needs. + Two alternatives were considered: - **`confirm` argument:** a `confirm` argument carrying the directory's name, as in DirXMLDevWeb. This is weaker: the agent can always pass it. @@ -176,7 +185,7 @@ needs OSP. A directory without it works as above, and the Identity Applications - **Reads:** my approval tasks; a request's status; a person's role and resource assignments; the request definitions a person can start. - **Writes:** approve or deny a task; request a role or a resource; start a request definition. - - **Production:** writes follow the production rule above. + - **Approval:** writes follow the approval setting above. - **Checked on the lab** (Identity Applications 24.4, 2026-10-30): - **Lifetimes:** OSP sets them in `ism-configuration.properties`. `com.netiq.idm.osp.oauth.refreshTokenTTL` is 2592000 s (30 days) for a client with a secret, such as `rbpmrest`. `…public.refreshTokenTTL` is 2700 s for public @@ -196,6 +205,43 @@ needs OSP. A directory without it works as above, and the Identity Applications past `sessionTTL` (2700 s), and the new access token worked (`tasks/list` 200). An idle key's token therefore stays usable for the refresh token's 30 days, not just the 45-minute session. +## Using it + +Phase 1 (0.4.0) has keys, the endpoint and the read tools. + +1. **An administrator turns keys on:** Settings > Directory > Agent keys. Keys need the directory's service account, + with Supervisor over the people who make keys. The same section sets the longest life (1 to 90 days, 90 by + default) and whether agents' changes wait for approval. +2. **A person makes a key:** under their name > Agent keys > New agent key: a label, a life, and what it may do + (reading always; changing objects or running Identity Manager only with those roles). Direct sign-in types the + password again; single sign-on needs a sign-in within the last 10 minutes. The key is shown once, with the + command for Claude Code: + + ```bash + claude mcp add --transport http directory-console https://console.example.com/mcp --header "Authorization: Bearer dc_…" + ``` + + Other clients take the same address and header. The agent's machine must trust the console's certificate. +3. **The agent calls `whoami` first,** then the read tools: + + | Tool | What it reads | + |---|---| + | `whoami` | the person, the directory and its tier, the key's roles and expiry | + | `search_objects`, `read_object`, `list_children`, `object_servers` | directory objects (secret attributes only as set) | + | `account_status`, `group_memberships`, `group_members` | sign-in status and lockout, groups | + | `object_associations` | the object inspector: associations and password synchronization | + | `idm_status`, `list_driver_sets`, `driver_set`, `driver_status`, `driver_health` | Identity Manager's drivers and their state | + | `list_jobs`, `job_definition` | jobs | + | `driver_password_sync`, `driver_associations`, `driver_cache`, `driver_cache_events` | a driver's password synchronization, association and cache inspectors | + | `driver_set_statistics`, `versions` | statistics and versions, per server | + + Objects, drivers, driver sets and jobs are named by GUID or DN. A `server` argument asks another server's + replica or engine. + +A key works only at `/mcp`: the API's other routes ignore it. Each tool call goes through the API in-process, as the +key's session. Revoking a key, turning keys off for the directory, or a locked, disabled or expired account stops it +at the next call. + ## Not in scope - Development work: policies, deploys and promotion stay in DirXMLDevWeb. @@ -208,7 +254,8 @@ needs OSP. A directory without it works as above, and the Identity Applications 2. **The role ceiling:** viewer, editor and operator, never admin. Default: read only. 3. **Roles under single sign-on:** keep the grants from the last sign-in, narrowed at the next one. These keys get a shorter longest life: 14 days. -4. **Production writes:** pending changes, approved in the console. +4. **Agents' writes:** pending changes, approved in the console. Changed the same day: approval is a per-directory + setting, required by default, that an administrator can turn off. 5. **Setting passwords:** generated, then applied by an operator in the console. 6. **The Identity Applications token:** one password grant when the key is made, then refresh tokens. The lab confirmed a 30-day lifetime that outlives OSP's session, and that refreshes do not touch `oidpInstanceData`. diff --git a/docs/STATUS.md b/docs/STATUS.md index 15d682e..297de52 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -15,6 +15,7 @@ It is a beta release. It has been tested against lab trees running eDirectory 9. | eDirectory | Partitions and replicas, servers, indexes (taking them offline and back, copying them to other servers), LDAP servers and groups (eDirectory > LDAP: ports, TLS, bind restrictions, limits, referrals, trace, class and attribute mappings) with refresh and reload, CEF auditing settings (eDirectory > Auditing, per server), SNMP traps (eDirectory > SNMP), encrypted attribute policies (eDirectory > Encryption) and, on a server's page, its encryption policy and replica filter, encrypted replication (read-only, deprecated), password policies (with testing a password against one, and generating one from it), challenge sets, login methods, enhanced background authentication. | | Certificates | The organizational CA and its configuration, server and user certificates, repairing the default server certificates, CRLs, export with the private key (PKCS#12), import of a server certificate from a PKCS#12 file in eDirectory's own form, trusted root containers and roots (made and removed). | | Identity Manager | **Drivers and driver sets:** state, start and stop (also all at once), start options, trace and log levels, named and Remote Loader passwords, properties (read-only), statistics, versions. **Jobs:** run, abort, create, edit (enabled, schedule, servers, objects and parameters), delete, and their named passwords, set on each server that runs the job. **Inspection:** the cache inspector, the association inspector, the object inspector, per-user password synchronization status. **Data:** migrate and synchronize, mapping tables. | +| Agents | AI agents use the console over MCP (`/mcp`) with an agent key a person makes under their name > Agent keys, acting as that person with their own rights and at most the key's roles. The tools read: objects, groups, sign-in status, drivers, jobs, the association, cache and object inspectors, statistics and versions. Off until an administrator turns keys on for a directory ([MCP.md](MCP.md)). | | Console | One Java 21 server, published as a container image: with Docker or on Kubernetes, or (discouraged) as a plain Java program ([DEPLOYING.md](DEPLOYING.md)). Its own TLS certificate, uploaded (PKCS#12 or PEM) or from an ACME CA with DNS-01 through RFC 2136 updates and renewed automatically (Settings > Console certificate), a daily certificate expiry check (its own, its trust store's, and the PKI of each directory with a service account) with e-mail and signed webhook notices (Settings > Certificate expiry), audit entries forwarded to syslog as CEF over RFC 5424, by TLS, TCP or UDP (Settings > Syslog), a trust store with fingerprint confirmation (Settings > Trusted certificates), also used beside Java's public roots for single sign-on providers, ACME CAs and syslog over TLS, versioned configuration with encrypted secrets and its history with restore of any earlier version (Settings > History and backup), passphrase-encrypted backups downloaded from the console and restored at the next start, a hash-chained audit log with filters, CSV and JSON Lines export and a chain check (Audit log), trusted reverse proxies. Shows its version, and tells administrators when a newer release is out (a daily check that can be turned off). | | Interface | Help for each page and tab (the ? in the header), with hover hints and field help from one glossary. Disabled buttons say why. Consequential actions ask first: resynchronise, migrate, abort a job, remove a named password, remove the service account, unassign a password policy. | | Container | Published for amd64 and arm64. Distroless, non-root, read-only root file system. No OpenText code, and nothing to mount for Identity Manager. | @@ -42,6 +43,7 @@ It is a beta release. It has been tested against lab trees running eDirectory 9. - extra password policy options. - **Identity Manager:** work orders, e-mail templates and RBE policies; viewing a driver's policies and its trace output (Designer and DirXMLDevWeb do these today). - **Search:** saved searches shared between people. +- **Agents:** tools that make changes (with approval in the console, on by default) and Identity Applications tools ([MCP.md](MCP.md)). ## Known issues From 3ce8349c2d42444e3b109c46c068593bbab74e43 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Fri, 30 Oct 2026 17:30:25 -0500 Subject: [PATCH 3/3] LiveAgentsCheck: agent keys and /mcp against a lab tree A test user made for the run (no password) signs in through a fake provider, makes a key, and its agent calls /mcp: whoami, reads under the control, the object inspector, Identity Manager under the user's rights; then a locked, an unlocked and a disabled account, and a revoked key. The container is deleted at the end. Passed on idm254 (eDirectory 9.3.3, IDM 4.10.2), 2026-10-30. Co-Authored-By: Claude Opus 5.5 --- .../console/server/live/LiveAgentsCheck.java | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 console-server/src/test/java/com/pointblue/console/server/live/LiveAgentsCheck.java diff --git a/console-server/src/test/java/com/pointblue/console/server/live/LiveAgentsCheck.java b/console-server/src/test/java/com/pointblue/console/server/live/LiveAgentsCheck.java new file mode 100644 index 0000000..be8b71b --- /dev/null +++ b/console-server/src/test/java/com/pointblue/console/server/live/LiveAgentsCheck.java @@ -0,0 +1,255 @@ +package com.pointblue.console.server.live; + +import static com.pointblue.console.server.live.LiveSetupCheck.call; + +import com.pointblue.console.server.auth.FakeIdp; +import com.pointblue.console.server.data.DataDir; +import com.pointblue.console.server.http.Phase3Access; +import com.pointblue.console.server.http.Server; +import com.pointblue.console.server.http.ServerClient; +import com.pointblue.dirxml.dev.LegacyTls; +import com.pointblue.dirxml.dev.deploy.Environments; +import com.pointblue.dirxml.dev.json.Json; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Live check of agent keys and the MCP server against a lab tree (docs/MCP.md, phase 1). As {@link LivePhase3Check}: + * the environment's account is the console's service account and admin, and a fake identity provider on loopback + * vouches for a test user made for the run in a new container under {@code parent} (no password is set or used). The + * admin turns agent keys on; the test user makes a key and its agent calls {@code /mcp}: who it is, reads under the + * user's own rights, Identity Manager, then refusals for a locked and a disabled account and a revoked key. The key + * is never printed. The container is deleted at the end. Not a JUnit test. + * + * java -cp … LiveAgentsCheck + */ +public final class LiveAgentsCheck { + + private static Map admin; + private static final String TREE = "lab"; + private static int rpcId; + + public static void main(String[] args) throws Exception { + LegacyTls.enable(); + Environments.Environment env = Environments.load(Path.of(args[0])).get(args[1]); + if (env.tier != null && env.tier.name().equalsIgnoreCase("prd")) { + throw new IllegalStateException("refusing a prd-tier environment"); + } + URI u = URI.create(env.url); + Path dataPath = Files.createTempDirectory("dc-live-agents-"); + Server server = new Server(new DataDir(dataPath), ServerClient.loopback(), "https://localhost:8443", new PrintStream(new ByteArrayOutputStream())); + String container = null; + try (FakeIdp idp = new FakeIdp()) { + server.start(); + LiveSetupCheck.base = "https://127.0.0.1:" + server.port(); + LiveSetupCheck.http = ServerClient.trusting(server); + setUp(dataPath, u.getHost(), u.getPort() > 0 ? u.getPort() : 636, env.bindDn, env.password); + System.out.println("# Agent keys live check: " + args[1] + " (service account and admin: the environment's account)"); + + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String uid = "dcag-" + ts; + String parent = (String) call("GET", "/api/v2/trees/" + TREE + "/objects?dn=" + enc(args[2]), null, admin).body().get("guid"); + LiveSetupCheck.Reply c = call("POST", "/api/v2/trees/" + TREE + "/objects/" + parent + "/children", + Map.of("objectClass", "organizationalUnit", "name", Map.of("type", "ou", "value", uid)), admin); + container = (String) c.body().get("guid"); + ok("create container", c.status() == 201, c.body().get("dn")); + LiveSetupCheck.Reply user = call("POST", "/api/v2/trees/" + TREE + "/objects/" + container + "/children", + Map.of("objectClass", "inetOrgPerson", "name", Map.of("type", "cn", "value", "agent-tester"), + "attributes", Map.of("sn", List.of("Tester"), "uid", List.of(uid))), admin); + String userDn = String.valueOf(user.body().get("dn")); + String userGuid = (String) user.body().get("guid"); + ok("create the test user (no password)", user.status() == 201, userDn); + + Phase3Access.singleSignOn(server, idp.issuer, List.of(args[2])); + idp.person("tester", Map.of("name", "Agent Tester", "preferred_username", uid, "groups", List.of("testers"))); + + LiveSetupCheck.Reply on = call("PUT", "/api/v2/directories/" + TREE + "/agent-keys", Map.of("enabled", true, "maxDays", 30), admin); + ok("the admin turns agent keys on", on.status() == 200, Json.asMap(on.body().get("agentKeys"))); + + LiveSetupCheck.Reply cb = sso(idp, "tester"); + Map me = cookie(cb); + Map who = call("GET", "/api/v2/session", null, me).body(); + me.put("X-CSRF-Token", String.valueOf(who.get("csrf"))); + ok("the test user signs in with single sign-on", userDn.equalsIgnoreCase(String.valueOf(who.get("dn"))), who.get("dn") + " via " + who.get("via")); + + LiveSetupCheck.Reply long_ = call("POST", "/api/v2/agent-keys", Map.of("label", "live check", "days", 15), me); + ok("a single sign-on key lives at most 14 days", long_.status() == 400, long_.status() + detail(long_)); + LiveSetupCheck.Reply made = call("POST", "/api/v2/agent-keys", Map.of("label", "live check", "days", 1, "roles", List.of("editor")), me); + ok("make a key (viewer and editor)", made.status() == 201, made.status() + detail(made) + " roles " + (made.status() == 201 ? Json.asMap(made.body().get("key")).get("roles") : "")); + String key = String.valueOf(made.body().get("token")); + String keyId = made.status() == 201 ? String.valueOf(Json.asMap(made.body().get("key")).get("id")) : ""; + + LiveSetupCheck.Reply init = mcp(key, "initialize", Map.of("protocolVersion", "2025-06-18")); + ok("MCP initialize", init.status() == 200, init.status() == 200 ? Json.asMap(init.body().get("result")).get("serverInfo") : init.status() + detail(init)); + List tools = Json.asList(Json.asMap(mcp(key, "tools/list", Map.of()).body().get("result")).get("tools")); + ok("tools/list", tools.size() >= 20, tools.size() + " tools"); + + Map whoami = tool(key, "whoami", Map.of()); + ok("whoami: the test user, via agent", userDn.equalsIgnoreCase(String.valueOf(whoami.get("dn"))) && "agent".equals(whoami.get("via")), + whoami.get("dn") + " via " + whoami.get("via") + " roles " + whoami.get("roles")); + Map self = tool(key, "read_object", Map.of("object", userDn)); + ok("read_object: its own entry, by DN, under the control", userGuid.equals(self.get("guid")), "attributes=" + Json.asMap(self.getOrDefault("attributes", Map.of())).keySet()); + Map found = tool(key, "search_objects", Map.of("filter", "(uid=" + uid + ")", "base", args[2])); + ok("search_objects finds the test user", Json.asList(found.get("items")).size() == 1, "total " + found.get("total")); + Map account = tool(key, "account_status", Map.of("object", userGuid)); + ok("account_status", !account.containsKey("_error"), account.containsKey("_error") ? account : "disabled=" + account.get("disabled") + " locked=" + account.get("locked")); + Map groups = tool(key, "group_memberships", Map.of("object", userGuid)); + ok("group_memberships", !groups.containsKey("_error"), groups.containsKey("_error") ? groups : "total " + groups.get("total")); + Map assoc = tool(key, "object_associations", Map.of("object", userGuid)); + ok("object_associations (the object inspector)", !assoc.containsKey("_error"), assoc.containsKey("_error") ? assoc : assoc.keySet()); + + Map idm = tool(key, "idm_status", Map.of()); + ok("idm_status: the engine, acting as the test user", Boolean.TRUE.equals(idm.get("available")), "engine " + idm.get("engineVersion") + + (idm.get("problem") == null ? "" : " problem: " + idm.get("problem"))); + Map sets = tool(key, "list_driver_sets", Map.of()); + ok("list_driver_sets (as the test user)", !sets.containsKey("_error"), sets.containsKey("_error") ? sets : Json.asList(sets.get("items")).size() + " visible to the user"); + List adminSets = (List) call("GET", "/api/v2/trees/" + TREE + "/idm/driversets", null, admin).body().get("items"); + if (adminSets != null && !adminSets.isEmpty()) { + Map set = Json.asMap(adminSets.get(0)); + Map asKey = tool(key, "driver_set", Map.of("driver_set", String.valueOf(set.get("dn")))); + ok("driver_set by DN: answered with the user's rights (refused, or the drivers they may see)", true, + asKey.containsKey("_error") ? "refused: " + asKey.get("title") : Json.asList(asKey.get("drivers")).size() + " drivers"); + } + + // eDirectory accepts the control for an account locked by intruder detection; the console refuses the key + String future = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'").format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).plusHours(1)); + LiveSetupCheck.Reply lock = call("PATCH", "/api/v2/trees/" + TREE + "/objects/" + userGuid, Map.of("changes", List.of( + Map.of("op", "replace", "attribute", "lockedByIntruder", "values", List.of("TRUE")), + Map.of("op", "replace", "attribute", "loginIntruderResetTime", "values", List.of(future)))), withEtag(admin, userGuid)); + ok("lock the test user until " + future, lock.status() == 200, lock.status() + detail(lock)); + LiveSetupCheck.Reply locked = mcp(key, "tools/list", Map.of()); + ok("a locked account's key is refused", locked.status() == 403 && "locked".equals(locked.body().get("title")), locked.status() + detail(locked)); + LiveSetupCheck.Reply unlock = call("POST", "/api/v2/trees/" + TREE + "/objects/" + userGuid + "/unlock", Map.of(), admin); + ok("unlock the test user", unlock.status() == 200, unlock.status() + detail(unlock)); + ok("unlocked, the key works again", mcp(key, "tools/list", Map.of()).status() == 200, ""); + + LiveSetupCheck.Reply acct = call("GET", "/api/v2/trees/" + TREE + "/objects/" + userGuid + "/account", null, admin); + Map h = new HashMap<>(admin); + h.put("If-Match", acct.raw().headers().firstValue("ETag").orElse("*")); + ok("disable the test user", call("PATCH", "/api/v2/trees/" + TREE + "/objects/" + userGuid + "/account", Map.of("disabled", true), h).status() == 200, ""); + LiveSetupCheck.Reply disabled = mcp(key, "tools/list", Map.of()); + ok("a disabled account's key is refused", disabled.status() == 403 && "account-disabled".equals(disabled.body().get("title")), disabled.status() + detail(disabled)); + + LiveSetupCheck.Reply revoke = call("DELETE", "/api/v2/agent-keys/" + keyId, null, admin); + ok("the admin revokes the key", revoke.status() == 204, revoke.status() + detail(revoke)); + ok("a revoked key is refused", mcp(key, "tools/list", Map.of()).status() == 401, ""); + + String audit = audit(dataPath); + ok("audited: key made, refusals, revoked", audit.contains("\"agent-key.create\"") && audit.contains("\"agent-key.use\"") && audit.contains("\"agent-key.revoke\""), ""); + ok("the key is not in the console's files", !Files.readString(dataPath.resolve("agents/keys.json")).contains(key.substring(3)) && !audit.contains(key.substring(3)), ""); + } finally { + if (container != null) { + LiveSetupCheck.Reply count = call("GET", "/api/v2/trees/" + TREE + "/objects/" + container + "/subtree", null, admin); + Map h = new HashMap<>(admin); + h.put("If-Match", "*"); + h.put("X-Confirm-Count", String.valueOf(count.body().get("count"))); + LiveSetupCheck.Reply d = call("DELETE", "/api/v2/trees/" + TREE + "/objects/" + container + "?subtree=true", null, h); + ok("cleanup: subtree delete", d.status() == 200, "deleted=" + d.body().get("deleted") + detail(d)); + } + server.stop(); + try (var s = Files.walk(dataPath)) { + s.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } + + private static LiveSetupCheck.Reply mcp(String key, String method, Map params) throws Exception { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", ++rpcId); + m.put("method", method); + m.put("params", params); + return call("POST", "/mcp", m, Map.of("Authorization", "Bearer " + key)); + } + + /** A tool's answer, parsed; a refusal carries {@code _error}. */ + private static Map tool(String key, String name, Map args) throws Exception { + LiveSetupCheck.Reply r = mcp(key, "tools/call", Map.of("name", name, "arguments", args)); + if (r.status() != 200 || r.body().get("result") == null) { + return Map.of("_error", r.status() + " " + r.body()); + } + Map result = Json.asMap(r.body().get("result")); + Object o = Json.parse(String.valueOf(Json.asMap(Json.asList(result.get("content")).get(0)).get("text"))); + Map out = new LinkedHashMap<>(o instanceof Map ? Json.asMap(o) : Map.of("items", o)); + if (Boolean.TRUE.equals(result.get("isError"))) { + out.put("_error", true); + } + return out; + } + + private static String audit(Path dataPath) throws Exception { + StringBuilder b = new StringBuilder(); + try (var files = Files.list(dataPath.resolve("audit"))) { + for (Path p : files.toList()) { + b.append(Files.readString(p)); + } + } + return b.toString(); + } + + private static Map withEtag(Map h, String guid) throws Exception { + Map out = new HashMap<>(h); + out.put("If-Match", call("GET", "/api/v2/trees/" + TREE + "/objects/" + guid, null, h).raw().headers().firstValue("ETag").orElse("*")); + return out; + } + + private static LiveSetupCheck.Reply sso(FakeIdp idp, String person) throws Exception { + idp.signInAs = person; + LiveSetupCheck.Reply start = call("GET", "/api/v2/oidc/start?directory=" + TREE + "&next=/", null, Map.of()); + String state = start.raw().headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith("__Host-console-oidc=")).findFirst().orElseThrow(); + HttpResponse atIdp = LiveSetupCheck.http.send(HttpRequest.newBuilder(URI.create(start.raw().headers().firstValue("Location").orElseThrow())).GET().build(), + HttpResponse.BodyHandlers.ofString()); + String back = atIdp.headers().firstValue("Location").orElseThrow(); + return call("GET", back.substring("https://localhost:8443".length()), null, Map.of("Cookie", state.substring(0, state.indexOf(';')))); + } + + private static Map cookie(LiveSetupCheck.Reply r) { + String c = r.raw().headers().allValues("Set-Cookie").stream().filter(x -> x.startsWith("__Host-console=")).findFirst().orElse("x=;"); + return new HashMap<>(Map.of("Cookie", c.substring(0, c.indexOf(';')))); + } + + private static void setUp(Path dataPath, String host, int port, String dn, String pw) throws Exception { + Matcher m = Pattern.compile("Setup token: ([0-9a-f]{32})").matcher(Files.readString(dataPath.resolve("bootstrap.txt"))); + m.find(); + Map t = Map.of("X-Setup-Token", m.group(1)); + call("PUT", "/api/v2/setup/directory", Map.of("id", TREE, "name", TREE, "host", host, "port", port, "tier", "lab"), t); + LiveSetupCheck.Reply probe = call("POST", "/api/v2/setup/probe", Map.of(), t); + int rec = Json.asInt(probe.body().get("recommended"), 0); + String sha = (String) Json.asMap(((List) probe.body().get("chain")).get(rec)).get("sha256"); + String tail = sha.replace(":", "").toLowerCase(); + call("POST", "/api/v2/setup/trust", Map.of("probeId", probe.body().get("probeId"), "index", rec, "confirm", tail.substring(tail.length() - 8)), t); + call("POST", "/api/v2/setup/authority", Map.of("dn", dn, "password", pw), t); + call("PUT", "/api/v2/setup/service-account", Map.of("dn", dn, "password", pw), t); + call("POST", "/api/v2/setup/finish", Map.of(), t); + LiveSetupCheck.Reply in = call("POST", "/api/v2/session", Map.of("directory", TREE, "name", dn, "password", pw), Map.of()); + String cookie = in.raw().headers().firstValue("Set-Cookie").orElse(""); + admin = new HashMap<>(Map.of("Cookie", cookie.substring(0, cookie.indexOf(';')))); + admin.put("X-CSRF-Token", String.valueOf(in.body().get("csrf"))); + } + + private static String enc(String s) { + return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8); + } + + private static String detail(LiveSetupCheck.Reply r) { + return r.status() / 100 == 2 ? "" : " " + r.body().get("title") + ": " + r.body().get("detail"); + } + + private static void ok(String what, boolean ok, Object detail) { + System.out.println("[" + (ok ? "ok" : "FAIL") + "] " + what + ": " + detail); + } +}