diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java index 2dfbcbe9a4e..29c4ac58b82 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java @@ -161,7 +161,7 @@ public class DomainFlowUtils { + "name collision."; /** Strict validator for ascii lowercase letters, digits, and "-", allowing "." as a separator */ - private static final CharMatcher ALLOWED_CHARS = + public static final CharMatcher ALLOWED_CHARS = CharMatcher.inRange('a', 'z').or(CharMatcher.inRange('0', '9').or(CharMatcher.anyOf("-."))); /** Default validator used to determine if an IDN name can be provisioned on a TLD. */ diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java index 8f5618eb5e0..958b279d4f1 100644 --- a/core/src/main/java/google/registry/model/registrar/Registrar.java +++ b/core/src/main/java/google/registry/model/registrar/Registrar.java @@ -25,6 +25,7 @@ import static com.google.common.collect.Streams.stream; import static com.google.common.io.BaseEncoding.base64; import static google.registry.config.RegistryConfig.getDefaultRegistrarWhoisServer; +import static google.registry.flows.domain.DomainFlowUtils.ALLOWED_CHARS; import static google.registry.model.CacheUtils.memoizeWithShortExpiration; import static google.registry.model.tld.Tlds.assertTldsExist; import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm; @@ -32,6 +33,7 @@ import static google.registry.util.CollectionUtils.nullToEmptyImmutableCopy; import static google.registry.util.CollectionUtils.nullToEmptyImmutableSortedCopy; import static google.registry.util.DateTimeUtils.START_INSTANT; +import static google.registry.util.DomainNameUtils.canonicalizeHostname; import static google.registry.util.PasswordUtils.SALT_SUPPLIER; import static google.registry.util.PasswordUtils.hashPassword; import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; @@ -50,6 +52,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Range; import com.google.common.collect.Sets; +import com.google.common.net.InternetDomainName; import com.google.gson.annotations.Expose; import com.google.re2j.Pattern; import google.registry.model.Buildable; @@ -185,6 +188,12 @@ public enum State { /** Regex for E.164 phone number format. */ private static final Pattern E164_PATTERN = Pattern.compile("\\+[0-9]{1,3}\\.[0-9]{1,14}"); + /** Maximum number of hostnames in the EPP client certificate allowlist. */ + public static final int MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES = 20; + + /** Maximum number of revoked client certificate hashes. */ + public static final int MAX_REVOKED_CLIENT_CERTIFICATE_HASHES = 20; + /** Regex for telephone support passcode (5 digit string). */ public static final Pattern PHONE_PASSCODE_PATTERN = Pattern.compile("\\d{5}"); @@ -295,6 +304,23 @@ public enum State { /** Base64 encoded SHA256 hash of {@link #failoverClientCertificate}. */ String failoverClientCertificateHash; + /** + * Hostnames for which a client certificate issued by a publicly trusted CA is accepted at EPP + * login, as an alternative to the pinned {@link #clientCertificate}. + * + *

Entries are stored in canonical A-label (punycode) lowercase form without a trailing dot. + * They are only consulted while the {@code ALLOW_EPP_CLIENT_CERT_HOSTNAME_AUTH} feature flag is + * active. + */ + @Expose Set allowedEppClientCertificateHostnames; + + /** + * Base64 encoded SHA256 hashes of client certificates the registrar has revoked, in the same form + * as {@link #clientCertificateHash}. A certificate with one of these hashes is rejected at EPP + * login regardless of which mechanism would otherwise have accepted it. + */ + @Expose Set revokedClientCertificateHashes; + /** An allow list of netmasks (in CIDR notation) which the client is allowed to connect from. */ @org.hibernate.annotations.Type(CidrBlockListUserType.class) @Column(columnDefinition = "text[]") @@ -540,6 +566,14 @@ public ImmutableList getIpAddressAllowList() { return nullToEmptyImmutableCopy(ipAddressAllowList); } + public ImmutableSet getAllowedEppClientCertificateHostnames() { + return nullToEmptyImmutableCopy(allowedEppClientCertificateHostnames); + } + + public ImmutableSet getRevokedClientCertificateHashes() { + return nullToEmptyImmutableCopy(revokedClientCertificateHashes); + } + public RegistrarAddress getLocalizedAddress() { return localizedAddress; } @@ -662,6 +696,9 @@ public Map toJsonMap() { .put("clientCertificateHash", clientCertificateHash) .put("failoverClientCertificate", failoverClientCertificate) .put("failoverClientCertificateHash", failoverClientCertificateHash) + .putListOfStrings( + "allowedEppClientCertificateHostnames", allowedEppClientCertificateHostnames) + .putListOfStrings("revokedClientCertificateHashes", revokedClientCertificateHashes) .put("localizedAddress", localizedAddress) .put("internationalizedAddress", internationalizedAddress) .put("phoneNumber", phoneNumber) @@ -853,6 +890,51 @@ public Builder setLastPocVerificationDate(Instant now) { return this; } + /** + * Sets the hostnames for which CA-issued client certificates are accepted at EPP login. + * + *

Each entry is canonicalized to lowercase A-label (punycode) form and validated as a + * multi-label DNS name; duplicates collapse to one entry. + */ + public Builder setAllowedEppClientCertificateHostnames(Iterable hostnames) { + checkArgumentNotNull(hostnames, "EPP client certificate hostnames cannot be null"); + ImmutableSet validated = + stream(hostnames) + .map( + hostname -> { + InternetDomainName domainName = + InternetDomainName.from(canonicalizeHostname(hostname)); + // InternetDomainName allows underscores and single-label names; require + // strict ASCII LDH characters and at least two labels. + checkArgument( + ALLOWED_CHARS.matchesAllOf(domainName.toString()) && domainName.hasParent(), + "Not a valid fully qualified hostname: %s", + hostname); + return domainName.toString(); + }) + .collect(toImmutableSet()); + checkArgument( + validated.size() <= MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES, + "At most %s EPP client certificate hostnames may be configured, got %s", + MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES, + validated.size()); + getInstance().allowedEppClientCertificateHostnames = validated; + return this; + } + + /** Sets the hashes of client certificates that are rejected at EPP login. */ + public Builder setRevokedClientCertificateHashes(Iterable hashes) { + checkArgumentNotNull(hashes, "Revoked client certificate hashes cannot be null"); + ImmutableSet validated = ImmutableSet.copyOf(hashes); + checkArgument( + validated.size() <= MAX_REVOKED_CLIENT_CERTIFICATE_HASHES, + "At most %s revoked client certificates may be configured, got %s", + MAX_REVOKED_CLIENT_CERTIFICATE_HASHES, + validated.size()); + getInstance().revokedClientCertificateHashes = validated; + return this; + } + private static String calculateHash(String clientCertificate) { if (clientCertificate == null) { return null; diff --git a/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java index 392664269ba..63cbaaa15dd 100644 --- a/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java +++ b/core/src/main/java/google/registry/ui/server/console/settings/SecurityAction.java @@ -96,6 +96,20 @@ private void setResponse(Registrar savedRegistrar) { } try { + if (!savedRegistrar + .getAllowedEppClientCertificateHostnames() + .equals(registrarParameter.getAllowedEppClientCertificateHostnames())) { + updatedRegistrarBuilder.setAllowedEppClientCertificateHostnames( + registrarParameter.getAllowedEppClientCertificateHostnames()); + updates.add("EPP_CERT_HOSTNAMES_CHANGE"); + } + if (!savedRegistrar + .getRevokedClientCertificateHashes() + .equals(registrarParameter.getRevokedClientCertificateHashes())) { + updatedRegistrarBuilder.setRevokedClientCertificateHashes( + registrarParameter.getRevokedClientCertificateHashes()); + updates.add("REVOKED_CERTS_CHANGE"); + } if (!savedRegistrar .getClientCertificate() .equals(registrarParameter.getClientCertificate())) { @@ -116,7 +130,7 @@ private void setResponse(Registrar savedRegistrar) { updates.add("FAILOVER_SSL_CERT_CHANGE"); } } - } catch (InsecureCertificateException e) { + } catch (InsecureCertificateException | IllegalArgumentException e) { setFailedResponse(e.getMessage(), SC_BAD_REQUEST); return; } diff --git a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java index b92ef9cdcc3..b5698c729b8 100644 --- a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java +++ b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java @@ -802,4 +802,190 @@ void testSuccess_toJsonMap_includesExpiryAccessPeriodEnabled() { Registrar modified = registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build(); assertThat(modified.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", true); } + + @Test + void testSuccess_eppClientCertificateHostnames_defaultEmpty() { + assertThat(registrar.getAllowedEppClientCertificateHostnames()).isEmpty(); + assertThat(registrar.toJsonMap()) + .containsEntry("allowedEppClientCertificateHostnames", ImmutableList.of()); + } + + @Test + void testSuccess_eppClientCertificateHostnames_persistence() { + persistResource( + registrar + .asBuilder() + .setAllowedEppClientCertificateHostnames( + ImmutableList.of("epp.registrar.example", "epp2.registrar.example")) + .build()); + Registrar loaded = Registrar.loadByRegistrarId("registrar").get(); + assertThat(loaded.getAllowedEppClientCertificateHostnames()) + .containsExactly("epp.registrar.example", "epp2.registrar.example"); + assertThat((Iterable) loaded.toJsonMap().get("allowedEppClientCertificateHostnames")) + .containsExactly("epp.registrar.example", "epp2.registrar.example"); + } + + @Test + void testSuccess_eppClientCertificateHostnames_canonicalizedAndDeduplicated() { + Registrar modified = + registrar + .asBuilder() + .setAllowedEppClientCertificateHostnames( + ImmutableList.of( + "EPP.Example.COM", + "epp.example.com", + "Epp.Example.Com", + "b.example", + "epp.みんな")) + .build(); + assertThat(modified.getAllowedEppClientCertificateHostnames()) + .containsExactly("epp.example.com", "b.example", "epp.xn--q9jyb4c"); + } + + @Test + void testSuccess_eppClientCertificateHostnames_clearing() { + registrar = + registrar + .asBuilder() + .setAllowedEppClientCertificateHostnames(ImmutableList.of("epp.registrar.example")) + .build(); + registrar = + registrar.asBuilder().setAllowedEppClientCertificateHostnames(ImmutableList.of()).build(); + assertThat(registrar.getAllowedEppClientCertificateHostnames()).isEmpty(); + assertThat(registrar.allowedEppClientCertificateHostnames).isNull(); + } + + @Test + void testSuccess_eppClientCertificateHostnames_acceptsIdnALabelAndLongNames() { + String longLabel = "a".repeat(63); + Registrar modified = + registrar + .asBuilder() + .setAllowedEppClientCertificateHostnames( + ImmutableList.of( + "xn--q9jyb4c.registrar.example", + "epp.xn--q9jyb4c", + longLabel + ".example", + "a-b.c-d.example")) + .build(); + assertThat(modified.getAllowedEppClientCertificateHostnames()).hasSize(4); + } + + @Test + void testFailure_eppClientCertificateHostnames_invalidNames() { + ImmutableList invalid = + ImmutableList.of( + "", + "epp", + "epp.", + "*.registrar.example", + "https://epp.registrar.example", + "epp.registrar.example:700", + "epp.registrar.example/path", + "192.0.2.1", + "2001:db8::1", + "epp_1.registrar.example", + "-epp.registrar.example", + "epp-.registrar.example", + "epp.registrar.ex ample", + "a".repeat(64) + ".example", + ("a".repeat(63) + ".").repeat(4) + "example"); + for (String hostname : invalid) { + assertThrows( + IllegalArgumentException.class, + () -> + registrar + .asBuilder() + .setAllowedEppClientCertificateHostnames(ImmutableList.of(hostname))); + } + } + + @Test + void testFailure_eppClientCertificateHostnames_tooMany() { + ImmutableList.Builder hostnames = new ImmutableList.Builder<>(); + for (int i = 0; i <= Registrar.MAX_EPP_CLIENT_CERTIFICATE_HOSTNAMES; i++) { + hostnames.add(String.format("epp%d.registrar.example", i)); + } + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> registrar.asBuilder().setAllowedEppClientCertificateHostnames(hostnames.build())); + assertThat(thrown) + .hasMessageThat() + .contains("At most 20 EPP client certificate hostnames may be configured, got 21"); + } + + @Test + void testFailure_eppClientCertificateHostnames_null() { + assertThrows( + IllegalArgumentException.class, + () -> registrar.asBuilder().setAllowedEppClientCertificateHostnames(null)); + } + + @Test + void testSuccess_revokedClientCertificateHashes_defaultEmpty() { + assertThat(registrar.getRevokedClientCertificateHashes()).isEmpty(); + assertThat(registrar.toJsonMap()) + .containsEntry("revokedClientCertificateHashes", ImmutableList.of()); + } + + @Test + void testSuccess_revokedClientCertificateHashes_persistence() { + persistResource( + registrar + .asBuilder() + .setRevokedClientCertificateHashes( + ImmutableList.of(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH)) + .build()); + Registrar loaded = Registrar.loadByRegistrarId("registrar").get(); + assertThat(loaded.getRevokedClientCertificateHashes()) + .containsExactly(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH); + assertThat((Iterable) loaded.toJsonMap().get("revokedClientCertificateHashes")) + .containsExactly(SAMPLE_CERT_HASH, SAMPLE_CERT2_HASH); + } + + @Test + void testSuccess_revokedClientCertificateHashes_deduplicated() { + Registrar modified = + registrar + .asBuilder() + .setRevokedClientCertificateHashes(ImmutableList.of(SAMPLE_CERT_HASH, SAMPLE_CERT_HASH)) + .build(); + assertThat(modified.getRevokedClientCertificateHashes()).containsExactly(SAMPLE_CERT_HASH); + } + + @Test + void testSuccess_revokedClientCertificateHashes_clearing() { + registrar = + registrar + .asBuilder() + .setRevokedClientCertificateHashes(ImmutableList.of(SAMPLE_CERT_HASH)) + .build(); + registrar = registrar.asBuilder().setRevokedClientCertificateHashes(ImmutableList.of()).build(); + assertThat(registrar.getRevokedClientCertificateHashes()).isEmpty(); + assertThat(registrar.revokedClientCertificateHashes).isNull(); + } + + @Test + void testFailure_revokedClientCertificateHashes_tooMany() { + ImmutableList.Builder hashes = new ImmutableList.Builder<>(); + for (int i = 0; i <= Registrar.MAX_REVOKED_CLIENT_CERTIFICATE_HASHES; i++) { + // Vary the first character to produce distinct, well-formed hashes. + hashes.add((char) ('A' + i) + SAMPLE_CERT_HASH.substring(1)); + } + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> registrar.asBuilder().setRevokedClientCertificateHashes(hashes.build())); + assertThat(thrown) + .hasMessageThat() + .contains("At most 20 revoked client certificates may be configured, got 21"); + } + + @Test + void testFailure_revokedClientCertificateHashes_null() { + assertThrows( + IllegalArgumentException.class, + () -> registrar.asBuilder().setRevokedClientCertificateHashes(null)); + } } diff --git a/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java b/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java index f8436e12cf0..12ffaa385b3 100644 --- a/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/settings/SecurityActionTest.java @@ -16,14 +16,19 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.testing.CertificateSamples.SAMPLE_CERT2; +import static google.registry.testing.CertificateSamples.SAMPLE_CERT3_HASH; +import static google.registry.testing.CertificateSamples.SAMPLE_CERT_HASH; import static google.registry.testing.DatabaseHelper.loadRegistrar; import static google.registry.testing.DatabaseHelper.loadSingleton; +import static google.registry.testing.DatabaseHelper.persistResource; import static google.registry.testing.SqlHelper.saveRegistrar; import static google.registry.util.DateTimeUtils.START_INSTANT; import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static jakarta.servlet.http.HttpServletResponse.SC_OK; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSetMultimap; import com.google.common.collect.ImmutableSortedMap; @@ -163,6 +168,144 @@ void testFailure_validityPeriodTooLong_returnsSpecificError() throws IOException assertThat(response.getPayload()).isEqualTo(expectedError); } + @Test + void testSuccess_setAllowedEppClientCertificateHostnames() throws IOException { + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson( + ImmutableMap.of( + "allowedEppClientCertificateHostnames", + ImmutableList.of("EPP.Example.COM", "epp2.example.com", "epp.example.com"))), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + Registrar r = loadRegistrar(testRegistrar.getRegistrarId()); + assertThat(r.getAllowedEppClientCertificateHostnames()) + .containsExactly("epp.example.com", "epp2.example.com"); + ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); + assertThat(history.getDescription()).hasValue("registrarId|EPP_CERT_HOSTNAMES_CHANGE"); + } + + @Test + void testSuccess_unchangedAllowedEppClientCertificateHostnames() throws IOException { + persistResource( + testRegistrar + .asBuilder() + .setAllowedEppClientCertificateHostnames(ImmutableList.of("epp.example.com")) + .build()); + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson( + ImmutableMap.of( + "allowedEppClientCertificateHostnames", + ImmutableList.of("epp.example.com"), + "ipAddressAllowList", + ImmutableList.of("192.168.1.1/32"))), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); + assertThat(history.getDescription()).hasValue("registrarId|IP_CHANGE"); + } + + @Test + void testFailure_invalidAllowedEppClientCertificateHostname() throws IOException { + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson( + ImmutableMap.of( + "allowedEppClientCertificateHostnames", + ImmutableList.of("epp.example.com", "*.example.com"))), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat(response.getPayload()).contains("*.example.com"); + assertThat( + loadRegistrar(testRegistrar.getRegistrarId()).getAllowedEppClientCertificateHostnames()) + .isEmpty(); + } + + @Test + void testSuccess_setRevokedClientCertificateHashes() throws IOException { + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson( + ImmutableMap.of( + "revokedClientCertificateHashes", + ImmutableList.of(SAMPLE_CERT3_HASH, SAMPLE_CERT_HASH))), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + Registrar r = loadRegistrar(testRegistrar.getRegistrarId()); + assertThat(r.getRevokedClientCertificateHashes()) + .containsExactly(SAMPLE_CERT3_HASH, SAMPLE_CERT_HASH); + ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); + assertThat(history.getDescription()).hasValue("registrarId|REVOKED_CERTS_CHANGE"); + } + + @Test + void testSuccess_unchangedRevokedClientCertificateHashes() throws IOException { + persistResource( + testRegistrar + .asBuilder() + .setRevokedClientCertificateHashes(ImmutableList.of(SAMPLE_CERT_HASH)) + .build()); + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson( + ImmutableMap.of( + "revokedClientCertificateHashes", + ImmutableList.of(SAMPLE_CERT_HASH), + "ipAddressAllowList", + ImmutableList.of("192.168.1.1/32"))), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + ConsoleUpdateHistory history = loadSingleton(ConsoleUpdateHistory.class).get(); + assertThat(history.getDescription()).hasValue("registrarId|IP_CHANGE"); + } + + @Test + void testFailure_tooManyRevokedClientCertificateHashes() throws IOException { + ImmutableList.Builder hashes = new ImmutableList.Builder<>(); + for (int i = 0; i <= Registrar.MAX_REVOKED_CLIENT_CERTIFICATE_HASHES; i++) { + hashes.add((char) ('A' + i) + SAMPLE_CERT_HASH.substring(1)); + } + SecurityAction action = + createAction( + testRegistrar.getRegistrarId(), + securityJson(ImmutableMap.of("revokedClientCertificateHashes", hashes.build())), + lenientChecker()); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat(response.getPayload()) + .contains("At most 20 revoked client certificates may be configured, got 21"); + assertThat(loadRegistrar(testRegistrar.getRegistrarId()).getRevokedClientCertificateHashes()) + .isEmpty(); + } + + /** Builds the JSON body the console sends: the registrar ID plus the given security fields. */ + private static String securityJson(ImmutableMap fields) { + return GSON.toJson( + ImmutableMap.builder().put("registrarId", "registrarId").putAll(fields).build()); + } + + private CertificateChecker lenientChecker() { + clock.setTo(Instant.parse("2020-11-01T00:00:00Z")); + return new CertificateChecker( + ImmutableSortedMap.of(START_INSTANT, 20825, Instant.parse("2020-09-01T00:00:00Z"), 398), + 30, + 15, + 2048, + ImmutableSet.of("secp256r1", "secp384r1"), + clock); + } + private SecurityAction createAction( String registrarId, String jsonBody, CertificateChecker certificateChecker) throws IOException { diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated index d10fbb5f3fd..64b0146c5e3 100644 --- a/db/src/main/resources/sql/schema/db-schema.sql.generated +++ b/db/src/main/resources/sql/schema/db-schema.sql.generated @@ -504,6 +504,7 @@ create table "Registrar" ( registrar_id text not null, last_update_time timestamp(6) with time zone not null, + allowed_epp_client_certificate_hostnames text[], allowed_tlds text[], billing_account_map hstore, block_premium_names boolean not null, @@ -545,6 +546,7 @@ rdap_base_urls text[], registrar_name text not null, registry_lock_allowed boolean not null, + revoked_client_certificate_hashes text[], password_salt text, state text check ((state in ('PENDING','ACTIVE','SUSPENDED','DISABLED'))), type text not null check ((type in ('REAL','OTE','PDT','EXTERNAL_MONITORING','INTERNAL','MONITORING','TEST'))),