diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index d421df50a0c..c5c184ea7ef 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -23,6 +23,11 @@ import { SanitizedMySQLAccountWithResourceSchema, UpdateMySQLAccountSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { + CreateOracleAccountSchema, + SanitizedOracleAccountWithResourceSchema, + UpdateOracleAccountSchema +} from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresAccountSchema, @@ -128,5 +133,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamAccountEndpoints({ + server, + parentType: PamResource.OracleDB, + accountResponseSchema: SanitizedOracleAccountWithResourceSchema, + createAccountSchema: CreateOracleAccountSchema, + updateAccountSchema: UpdateOracleAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 1eecd69f33e..2781a987124 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -29,6 +29,10 @@ import { MySQLAccountCredentialsSchema, SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { + OracleAccountCredentialsSchema, + SanitizedOracleAccountWithResourceSchema +} from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas"; import { @@ -69,6 +73,7 @@ const SanitizedAccountSchema = z SanitizedRedisAccountWithResourceSchema, SanitizedAwsIamAccountWithResourceSchema, SanitizedWindowsAccountWithResourceSchema, + SanitizedOracleAccountWithResourceSchema, SanitizedActiveDirectoryAccountWithDomainSchema ]) .and( @@ -128,6 +133,10 @@ const AccountCredentialsResponseSchema = z.discriminatedUnion("parentType", [ parentType: z.literal(PamResource.Windows), credentials: WindowsAccountCredentialsSchema }), + AccountCredentialsBaseSchema.extend({ + parentType: z.literal(PamResource.OracleDB), + credentials: OracleAccountCredentialsSchema + }), AccountCredentialsBaseSchema.extend({ parentType: z.literal(PamDomainType.ActiveDirectory), credentials: ActiveDirectoryAccountCredentialsSchema @@ -557,6 +566,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Redis) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Kubernetes) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.OracleDB) }), GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Windows) }), // AWS IAM (no gateway, returns short-lived STS credentials usable by both CLI and console) z.object({ diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index ef58df57d7e..045197dc492 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -23,6 +23,11 @@ import { MySQLResourceSchema, UpdateMySQLResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { + CreateOracleResourceSchema, + OracleResourceSchema, + UpdateOracleResourceSchema +} from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, @@ -134,5 +139,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.OracleDB, + resourceResponseSchema: OracleResourceSchema, + createResourceSchema: CreateOracleResourceSchema, + updateResourceSchema: UpdateOracleResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index 6726a6db9cd..b345458a240 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -22,6 +22,10 @@ import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { + OracleResourceListItemSchema, + SanitizedOracleResourceSchema +} from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PamResource, PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PAM_AI_INSIGHT_MODELS } from "@app/ee/services/pam-resource/pam-resource-schemas"; import { @@ -54,7 +58,8 @@ const SanitizedResourceSchema = z.discriminatedUnion("resourceType", [ SanitizedAwsIamResourceSchema, SanitizedMongoDBResourceSchema, SanitizedRedisResourceSchema, - SanitizedWindowsResourceSchema + SanitizedWindowsResourceSchema, + SanitizedOracleResourceSchema ]); const SanitizedResourceWithFavoriteSchema = z.intersection( @@ -71,7 +76,8 @@ const ResourceOptionsSchema = z.discriminatedUnion("resource", [ AwsIamResourceListItemSchema, MongoDBResourceListItemSchema, RedisResourceListItemSchema, - WindowsResourceListItemSchema + WindowsResourceListItemSchema, + OracleResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 4282d0cdf38..6030916b08d 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -6,6 +6,7 @@ import { PolicyRulesResponseSchema } from "@app/ee/services/pam-account-policy"; import { KubernetesSessionCredentialsSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MongoDBSessionCredentialsSchema } from "@app/ee/services/pam-resource/mongodb/mongodb-resource-schemas"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { OracleSessionCredentialsSchema } from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { RedisSessionCredentialsSchema } from "@app/ee/services/pam-resource/redis/redis-resource-schemas"; import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; @@ -29,6 +30,7 @@ const SessionCredentialsSchema = z.union([ SSHSessionCredentialsSchema, PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema, + OracleSessionCredentialsSchema, MongoDBSessionCredentialsSchema, KubernetesSessionCredentialsSchema, RedisSessionCredentialsSchema, diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 5d3b8d6470d..d0593a92941 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -1044,6 +1044,7 @@ export const pamAccountServiceFactory = ({ case PamResource.Postgres: case PamResource.MySQL: case PamResource.MsSQL: + case PamResource.OracleDB: case PamResource.MongoDB: { const connectionCredentials = (await decryptResourceConnectionDetails({ diff --git a/backend/src/ee/services/pam-resource/oracle/oracle-resource-constants.ts b/backend/src/ee/services/pam-resource/oracle/oracle-resource-constants.ts new file mode 100644 index 00000000000..91ea924901c --- /dev/null +++ b/backend/src/ee/services/pam-resource/oracle/oracle-resource-constants.ts @@ -0,0 +1 @@ +export const ORACLE_TLS_PROBE_TIMEOUT_MS = 10 * 1000; diff --git a/backend/src/ee/services/pam-resource/oracle/oracle-resource-factory.ts b/backend/src/ee/services/pam-resource/oracle/oracle-resource-factory.ts new file mode 100644 index 00000000000..ba9dbf3344d --- /dev/null +++ b/backend/src/ee/services/pam-resource/oracle/oracle-resource-factory.ts @@ -0,0 +1,223 @@ +import oracledb from "oracledb"; + +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; + +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials, + TPamResourceInternalMetadata +} from "../pam-resource-types"; +import { probeOracleTls } from "./oracle-resource-fns"; +import { TOracleAccountCredentials, TOracleResourceConnectionDetails } from "./oracle-resource-types"; + +const ORACLE_CONNECT_TIMEOUT_SECONDS = 30; +const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test"; +const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password"; + +interface OracleResourceConnection { + validate: (connectOnly: boolean) => Promise; + rotateCredentials: () => Promise; + close: () => Promise; +} + +const makeOracleConnection = ( + proxyPort: number, + config: { + connectionDetails: TOracleResourceConnectionDetails; + username?: string; + password?: string; + } +): OracleResourceConnection => { + const { connectionDetails } = config; + const { host, sslEnabled, sslRejectUnauthorized, sslCertificate } = connectionDetails; + const actualUsername = config.username ?? TEST_CONNECTION_USERNAME; + const actualPassword = config.password ?? TEST_CONNECTION_PASSWORD; + + const connectString = sslEnabled + ? `tcps://localhost:${proxyPort}/${connectionDetails.database}?ssl_server_dn_match=false` + : `localhost:${proxyPort}/${connectionDetails.database}`; + + const openConnection = () => + oracledb.getConnection({ + user: actualUsername, + password: actualPassword, + connectString, + connectTimeout: ORACLE_CONNECT_TIMEOUT_SECONDS, + transportConnectTimeout: ORACLE_CONNECT_TIMEOUT_SECONDS + }); + + return { + validate: async (connectOnly) => { + // When a custom CA is provided, probe the cert chain first — node-oracledb + // thin mode can't accept an inline CA, so tcps:// only works if the cert + // is already trusted by the system store. + if (sslEnabled && sslCertificate) { + try { + await probeOracleTls({ + tcpHost: "localhost", + port: proxyPort, + servername: host, + caPem: sslCertificate, + rejectUnauthorized: sslRejectUnauthorized + }); + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to Oracle: ${(error as Error).message || String(error)}` + }); + } + } + + let conn: oracledb.Connection | null = null; + try { + conn = await openConnection(); + await conn.execute("SELECT 1 FROM DUAL"); + } catch (error) { + if (error instanceof Error) { + const msg = error.message || ""; + if (connectOnly && msg.includes("ORA-")) { + // Any Oracle response means the host is reachable. + return; + } + } + // If CA was provided and probe passed, the cert is valid — the tcps:// + // failure is likely because the CA isn't in the system trust store. + // Save anyway; creds will be checked on first PAM session. + if (sslEnabled && sslCertificate) { + return; + } + throw new BadRequestError({ + message: `Unable to validate connection to Oracle: ${(error as Error).message || String(error)}` + }); + } finally { + if (conn) { + await conn.close().catch(() => undefined); + } + } + }, + rotateCredentials: async () => { + throw new BadRequestError({ message: "Credential rotation is not yet supported for Oracle resources" }); + }, + close: async () => {} + }; +}; + +const executeWithGateway = async ( + config: { + connectionDetails: TOracleResourceConnectionDetails; + gatewayId: string; + username?: string; + password?: string; + }, + gatewayV2Service: Pick, + operation: (connection: OracleResourceConnection) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const [targetHost] = await verifyHostInputValidity({ + host: connectionDetails.host, + isGateway: true, + isDynamicSecret: false + }); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + const connection = makeOracleConnection(proxyPort, config); + try { + return await operation(connection); + } finally { + await connection.close(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + +export const oracleResourceFactory: TPamResourceFactory< + TOracleResourceConnectionDetails, + TOracleAccountCredentials, + TPamResourceInternalMetadata +> = (_resourceType, connectionDetails, gatewayId, gatewayV2Service) => { + const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + + await executeWithGateway({ connectionDetails, gatewayId }, gatewayV2Service, async (client) => { + await client.validate(true); + }); + return connectionDetails; + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + + try { + await executeWithGateway( + { + connectionDetails, + gatewayId, + username: credentials.username, + password: credentials.password + }, + gatewayV2Service, + async (client) => { + await client.validate(false); + } + ); + return credentials; + } catch (error) { + if (error instanceof BadRequestError && error.message.includes("ORA-01017")) { + throw new BadRequestError({ + message: "Account credentials invalid: Username or password incorrect" + }); + } + throw error; + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async () => { + throw new BadRequestError({ message: "Credential rotation is not yet supported for Oracle resources" }); + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TOracleAccountCredentials, + currentCredentials: TOracleAccountCredentials + ) => { + if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + password: currentCredentials.password + }; + } + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/oracle/oracle-resource-fns.ts b/backend/src/ee/services/pam-resource/oracle/oracle-resource-fns.ts new file mode 100644 index 00000000000..79d3da4d7c0 --- /dev/null +++ b/backend/src/ee/services/pam-resource/oracle/oracle-resource-fns.ts @@ -0,0 +1,51 @@ +import net from "net"; +import tls from "tls"; + +import { ORACLE_TLS_PROBE_TIMEOUT_MS } from "./oracle-resource-constants"; +import { OracleResourceListItemSchema } from "./oracle-resource-schemas"; +import { TProbeOracleTlsArgs } from "./oracle-resource-types"; + +export const getOracleResourceListItem = () => { + return { + name: OracleResourceListItemSchema.shape.name.value, + resource: OracleResourceListItemSchema.shape.resource.value + }; +}; + +// TLS reachability + cert chain check. node-oracledb thin can't accept an inline CA. +export const probeOracleTls = ({ + tcpHost, + port, + servername, + caPem, + rejectUnauthorized, + timeoutMs = ORACLE_TLS_PROBE_TIMEOUT_MS +}: TProbeOracleTlsArgs): Promise => + new Promise((resolve, reject) => { + const socket = net.connect({ host: tcpHost, port }); + socket.setTimeout(timeoutMs); + socket.once("error", (err) => { + socket.destroy(); + reject(err); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("timeout connecting to Oracle listener")); + }); + socket.once("connect", () => { + const tlsSocket = tls.connect({ + socket, + servername, + ca: caPem || undefined, + rejectUnauthorized + }); + tlsSocket.once("secureConnect", () => { + tlsSocket.end(); + resolve(); + }); + tlsSocket.once("error", (err) => { + tlsSocket.destroy(); + reject(err); + }); + }); + }); diff --git a/backend/src/ee/services/pam-resource/oracle/oracle-resource-schemas.ts b/backend/src/ee/services/pam-resource/oracle/oracle-resource-schemas.ts new file mode 100644 index 00000000000..6721ebc15c7 --- /dev/null +++ b/backend/src/ee/services/pam-resource/oracle/oracle-resource-schemas.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreateGatewayPamResourceSchema, + BaseCreatePamAccountSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const OracleResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema.extend({ + database: z + .string() + .trim() + .min(1) + .max(128) + .regex( + /^[a-zA-Z][a-zA-Z0-9_.#$]*$/, + "Invalid Oracle service name: must start with a letter and contain only letters, digits, underscores, dots, # or $" + ) +}); +export const OracleAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +const BaseOracleResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.OracleDB) }); + +export const OracleResourceSchema = BaseOracleResourceSchema.extend({ + connectionDetails: OracleResourceConnectionDetailsSchema, + rotationAccountCredentials: OracleAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedOracleResourceSchema = BaseOracleResourceSchema.extend({ + connectionDetails: OracleResourceConnectionDetailsSchema, + rotationAccountCredentials: OracleAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + +export const OracleResourceListItemSchema = z.object({ + name: z.literal("Oracle Database"), + resource: z.literal(PamResource.OracleDB) +}); + +export const CreateOracleResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ + connectionDetails: OracleResourceConnectionDetailsSchema, + rotationAccountCredentials: OracleAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateOracleResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ + connectionDetails: OracleResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: OracleAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const OracleAccountSchema = BasePamAccountSchema.extend({ + credentials: OracleAccountCredentialsSchema +}); + +export const CreateOracleAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: OracleAccountCredentialsSchema +}); + +export const UpdateOracleAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: OracleAccountCredentialsSchema.optional() +}); + +export const SanitizedOracleAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + parentType: z.literal(PamResource.OracleDB), + credentials: OracleAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const OracleSessionCredentialsSchema = OracleResourceConnectionDetailsSchema.and(OracleAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/oracle/oracle-resource-types.ts b/backend/src/ee/services/pam-resource/oracle/oracle-resource-types.ts new file mode 100644 index 00000000000..92cffb9d25f --- /dev/null +++ b/backend/src/ee/services/pam-resource/oracle/oracle-resource-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { + OracleAccountCredentialsSchema, + OracleAccountSchema, + OracleResourceConnectionDetailsSchema, + OracleResourceSchema +} from "./oracle-resource-schemas"; + +export type TOracleResource = z.infer; +export type TOracleResourceConnectionDetails = z.infer; +export type TOracleAccount = z.infer; +export type TOracleAccountCredentials = z.infer; + +export type TProbeOracleTlsArgs = { + tcpHost: string; + port: number; + servername: string; + caPem: string | undefined; + rejectUnauthorized: boolean; + timeoutMs?: number; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index 6fdeb10fb3d..0258d03ecdb 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -7,7 +7,8 @@ export enum PamResource { AwsIam = "aws-iam", Redis = "redis", MongoDB = "mongodb", - Windows = "windows" + Windows = "windows", + OracleDB = "oracledb" } export enum PamResourceOrderBy { diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 6cbbbc23aca..4cc0348ee3e 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,6 +1,7 @@ import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory"; import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory"; import { mongodbResourceFactory } from "./mongodb/mongodb-resource-factory"; +import { oracleResourceFactory } from "./oracle/oracle-resource-factory"; import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, @@ -28,5 +29,6 @@ export const PAM_RESOURCE_FACTORY_MAP: Record { getPostgresResourceListItem(), getMySQLResourceListItem(), getMsSQLResourceListItem(), + getOracleResourceListItem(), getAwsIamResourceListItem(), getKubernetesResourceListItem(), getRedisResourceListItem(), diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 02f3bbfbd86..0cae6204c53 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -38,6 +38,12 @@ import { TMySQLResource, TMySQLResourceConnectionDetails } from "./mysql/mysql-resource-types"; +import { + TOracleAccount, + TOracleAccountCredentials, + TOracleResource, + TOracleResourceConnectionDetails +} from "./oracle/oracle-resource-types"; import { TPamResourceDALFactory } from "./pam-resource-dal"; import { PamResource, PamResourceOrderBy } from "./pam-resource-enums"; import { @@ -77,7 +83,8 @@ export type TPamResource = | TKubernetesResource | TRedisResource | TMongoDBResource - | TWindowsResource; + | TWindowsResource + | TOracleResource; export type TPamResourceWithFavorite = TPamResources & { isFavorite: boolean }; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails @@ -88,7 +95,8 @@ export type TPamResourceConnectionDetails = | TAwsIamResourceConnectionDetails | TRedisResourceConnectionDetails | TMongoDBResourceConnectionDetails - | TWindowsResourceConnectionDetails; + | TWindowsResourceConnectionDetails + | TOracleResourceConnectionDetails; export type TPamResourceInternalMetadata = TSSHResourceInternalMetadata | TWindowsResourceInternalMetadata; // Account types @@ -101,7 +109,8 @@ export type TPamAccount = | TKubernetesAccount | TRedisAccount | TMongoDBAccount - | TWindowsAccount; + | TWindowsAccount + | TOracleAccount; export type TPamAccountCredentials = | TPostgresAccountCredentials @@ -112,7 +121,8 @@ export type TPamAccountCredentials = | TAwsIamAccountCredentials | TRedisAccountCredentials | TMongoDBAccountCredentials - | TWindowsAccountCredentials; + | TWindowsAccountCredentials + | TOracleAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick & { diff --git a/docs/docs.json b/docs/docs.json index 24fcbd3879c..407b8d012d9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -906,6 +906,7 @@ "documentation/platform/pam/getting-started/resources/mongodb", "documentation/platform/pam/getting-started/resources/mssql", "documentation/platform/pam/getting-started/resources/mysql", + "documentation/platform/pam/getting-started/resources/oracle", "documentation/platform/pam/getting-started/resources/postgresql", "documentation/platform/pam/getting-started/resources/redis", "documentation/platform/pam/getting-started/resources/ssh", diff --git a/docs/documentation/platform/pam/getting-started/resources/oracle.mdx b/docs/documentation/platform/pam/getting-started/resources/oracle.mdx new file mode 100644 index 00000000000..98afcfb9ec8 --- /dev/null +++ b/docs/documentation/platform/pam/getting-started/resources/oracle.mdx @@ -0,0 +1,196 @@ +--- +title: "Oracle" +sidebarTitle: "Oracle" +description: "Learn how to configure Oracle Database access through Infisical PAM for secure, audited, and just-in-time access to your Oracle databases." +--- + +Infisical PAM supports secure, just-in-time access to Oracle Databases. +This allows your team to access Oracle without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Oracle access in Infisical PAM uses an Infisical Gateway to securely proxy connections to your Oracle server. When a user requests access, Infisical establishes a secure tunnel through the Gateway, enabling secure access without exposing your Oracle instance directly. + +```mermaid +sequenceDiagram + participant User + participant CLI as Infisical CLI + participant Infisical + participant Gateway as Infisical Gateway + participant Oracle as Oracle Server + + User->>CLI: Request Oracle access + CLI->>Infisical: Authenticate & request session + Infisical-->>CLI: Session credentials & Gateway info + CLI->>CLI: Start local proxy + CLI->>Gateway: Establish secure tunnel + Gateway->>Oracle: Establish connection + Gateway->>Oracle: Authenticate with credentials + User->>CLI: SQL queries + CLI->>Gateway: Proxy requests + Gateway->>Oracle: Forward queries + Oracle-->>Gateway: Response + Gateway-->>CLI: Return response + CLI-->>User: Query output +``` + +### Key Concepts + +1. **Gateway**: An Infisical Gateway deployed in your network that can reach the Oracle server. The Gateway handles secure communication between users and your Oracle instance. + +2. **Authentication**: Credentials (username/password) are stored securely in Infisical and used by the Gateway to authenticate with Oracle on behalf of the user. + +3. **Local Proxy**: The Infisical CLI starts a local proxy on your machine that intercepts Oracle connections and routes them securely through the Gateway to your Oracle instance. + +4. **Session Tracking**: All access sessions are logged, including when the session was created, who accessed the Oracle instance, session duration, and when it ended. + +### Session Tracking + +Infisical tracks: + +- When the session was created +- Who accessed which Oracle instance +- Session duration +- When the session ended + + + **Session Logs**: After ending a session (by stopping the proxy), you can view + detailed session logs in the Sessions page. + + +## Prerequisites + +Before configuring Oracle access in Infisical PAM, you need: + +1. **Infisical Gateway** - A Gateway deployed in your network with access to the Oracle server +2. **Oracle Credentials** - Username and password for the Oracle instance +3. **Infisical CLI** - The Infisical CLI installed on user machines + + + **Gateway Required**: Oracle access requires an Infisical Gateway to be + deployed and registered with your Infisical instance. The Gateway must have + network connectivity to your Oracle server. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your Oracle instance. + + + + Before creating the resource, ensure you have an Infisical Gateway running and registered with your Infisical instance. The Gateway must have network access to your Oracle server. + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **Oracle** + 3. Enter a **Name** for the resource (e.g., `production-oracle`, `staging-db`) + 4. Select the **Gateway** that has access to this Oracle instance + 5. Enter the **Host** - the hostname or IP address of your Oracle server (e.g., `oracle.example.com` or `192.168.1.100`) + 6. Enter the **Database Name** - the Oracle service name (e.g., `ORCL`, `XEPDB1`) + 7. Enter the **Port** - `1521` for plain TCP (default) or `2484` for TCPS + 8. Configure SSL/TLS options: + - **Enable SSL**: Toggle to connect to the Oracle TCPS listener + - **Reject Unauthorized**: Toggle to verify SSL certificates (enabled by default, recommended for production) + - **Trusted CA SSL Certificate**: Optional CA certificate for custom certificate authorities + + + **SSL Configuration**: When SSL is enabled, the Oracle TCPS listener is usually on port 2484. For self-signed certificates, you may need to provide the CA certificate or disable certificate validation (not recommended for production). + + + + + +## Create PAM Accounts + +Once you have configured the PAM resource, you'll need to configure a PAM account for your Oracle resource. +A PAM Account represents a specific set of credentials that users can request access to. You can create multiple accounts per resource, each with different permission levels. + + + + Go to the **Resources** tab in your PAM project and open the Oracle resource you created. + + + + Click **Add Account**. + + + + Fill in the account details: + + + A friendly name for this account (e.g., `readonly-user`, `admin-access`) + + + + An optional description for this account. + + + + The Oracle username. + + + + The Oracle password. + + + + When enabled, users must complete a multi-factor authentication (MFA) challenge before accessing this account. The MFA method used is determined by the organization's enforced method, the user's configured method, or email as a fallback. + + + + + +## Access Oracle Account + +Once your resource and accounts are configured, users can request access through the Infisical CLI: + + + + 1. Navigate to the **Resources** tab in your PAM project and open the Oracle resource + 2. In the resource's accounts section, find the account you want to access + 3. Click the **Access** button for that account + 4. Copy the provided CLI command + + The command follows this format: + ```bash + infisical pam db access --resource --account --project-id --duration --domain + ``` + + + + + Run the copied command in your terminal. + + The CLI will: + 1. Authenticate with Infisical + 2. Establish a secure connection through the Gateway + 3. Start a local proxy on your machine + 4. Display a local connection URL you can use to connect + + + + + Once the proxy is running, connect to Oracle using the connection details displayed by the CLI. Oracle's login protocol requires the client to send a password, so the CLI prints a fixed placeholder (`password`) — type it literally in your client. The Gateway swaps in the real credential during login. + + **Using sqlcl:** + ```bash + sql /password@localhost:/ + ``` + + **Using other clients:** + + You can also use GUI clients such as SQL Developer, DBeaver, DataGrip, or Toad (JDBC thin mode). Point them to `localhost` on the port shown in the CLI output with the username and service name from the connection details, and type `password` in the password field. + + + + + When you're done, stop the proxy by pressing `Ctrl+C` in the terminal where it's running. This will: + - Close the secure tunnel + - End the session + - Log the session details to Infisical + + You can view session logs in the **Sessions** page of your PAM project. + + + diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index f30e170d2ac..ac0b7a35571 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -13,6 +13,7 @@ import { TKubernetesAccount, TKubernetesResource } from "./kubernetes-resource"; import { TMongoDBAccount, TMongoDBResource } from "./mongodb-resource"; import { TMsSQLAccount, TMsSQLResource } from "./mssql-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; +import { TOracleDBAccount, TOracleDBResource } from "./oracledb-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TRedisAccount, TRedisResource } from "./redis-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource"; @@ -23,6 +24,7 @@ export * from "./kubernetes-resource"; export * from "./mongodb-resource"; export * from "./mssql-resource"; export * from "./mysql-resource"; +export * from "./oracledb-resource"; export * from "./postgres-resource"; export * from "./redis-resource"; export * from "./ssh-resource"; @@ -37,7 +39,8 @@ export type TPamResource = | TSSHResource | TAwsIamResource | TKubernetesResource - | TWindowsResource; + | TWindowsResource + | TOracleDBResource; export type TPamAccount = | TPostgresAccount @@ -48,7 +51,8 @@ export type TPamAccount = | TSSHAccount | TAwsIamAccount | TKubernetesAccount - | TWindowsAccount; + | TWindowsAccount + | TOracleDBAccount; export type TPamFolder = { id: string; diff --git a/frontend/src/hooks/api/pam/types/oracledb-resource.ts b/frontend/src/hooks/api/pam/types/oracledb-resource.ts new file mode 100644 index 00000000000..16eba813a9e --- /dev/null +++ b/frontend/src/hooks/api/pam/types/oracledb-resource.ts @@ -0,0 +1,14 @@ +import { PamResourceType } from "../enums"; +import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +// Resources +export type TOracleDBResource = TBasePamResource & { resourceType: PamResourceType.OracleDB } & { + connectionDetails: TBaseSqlConnectionDetails; +}; + +// Accounts +export type TOracleDBAccount = TBasePamAccount & { + credentials: TBaseSqlCredentials; +}; diff --git a/frontend/src/pages/pam/PamAccountByIDPage/components/PamAccountCredentialsSection.tsx b/frontend/src/pages/pam/PamAccountByIDPage/components/PamAccountCredentialsSection.tsx index d7d07176ed1..54e5ced807b 100644 --- a/frontend/src/pages/pam/PamAccountByIDPage/components/PamAccountCredentialsSection.tsx +++ b/frontend/src/pages/pam/PamAccountByIDPage/components/PamAccountCredentialsSection.tsx @@ -138,6 +138,7 @@ const CredentialsContent = ({ account }: { account: TPamAccount }) => { case PamResourceType.Postgres: case PamResourceType.MySQL: case PamResourceType.MsSQL: + case PamResourceType.OracleDB: case PamResourceType.MongoDB: case PamResourceType.Redis: return ; @@ -254,6 +255,7 @@ const getSensitiveFieldDefs = (account: TPamAccount): SensitiveFieldDef[] => { case PamResourceType.Postgres: case PamResourceType.MySQL: case PamResourceType.MsSQL: + case PamResourceType.OracleDB: case PamResourceType.MongoDB: case PamResourceType.Redis: case PamResourceType.Windows: diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index 72b0e7c2b4c..187196c8d04 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -86,6 +86,7 @@ export const PamAccessAccountModal = ({ case PamResourceType.Postgres: case PamResourceType.MySQL: case PamResourceType.MsSQL: + case PamResourceType.OracleDB: case PamResourceType.MongoDB: return base("db"); case PamResourceType.Redis: diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/OracleDBAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/OracleDBAccountForm.tsx new file mode 100644 index 00000000000..870918fdf6e --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/OracleDBAccountForm.tsx @@ -0,0 +1,81 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, SheetFooter } from "@app/components/v3"; +import { PamResourceType, TOracleDBAccount } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; + +import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; +import { SqlAccountFields } from "./shared/SqlAccountFields"; +import { + AccountPolicyField, + GenericAccountFields, + genericAccountFieldsSchema +} from "./GenericAccountFields"; +import { MetadataFields } from "./MetadataFields"; +import { RequireMfaField } from "./RequireMfaField"; + +type Props = { + account?: TOracleDBAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; + closeSheet: () => void; +}; + +const formSchema = genericAccountFieldsSchema.extend({ + credentials: BaseSqlAccountSchema, + requireMfa: z.boolean().nullable().optional() +}); + +type FormData = z.infer; + +export const OracleDBAccountForm = ({ account, onSubmit, closeSheet }: Props) => { + const isUpdate = Boolean(account); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: account + ? { + ...account, + credentials: { + ...account.credentials, + password: UNCHANGED_PASSWORD_SENTINEL + } + } + : undefined + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+
+ + + + + +
+ + + + +
+
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index 9d661431dea..19db0e490d5 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -14,6 +14,7 @@ import { KubernetesAccountForm } from "./KubernetesAccountForm"; import { MongoDBAccountForm } from "./MongoDBAccountForm"; import { MsSQLAccountForm } from "./MsSQLAccountForm"; import { MySQLAccountForm } from "./MySQLAccountForm"; +import { OracleDBAccountForm } from "./OracleDBAccountForm"; import { PostgresAccountForm } from "./PostgresAccountForm"; import { RedisAccountForm } from "./RedisAccountForm"; import { SshAccountForm } from "./SshAccountForm"; @@ -98,6 +99,15 @@ const CreateForm = ({ resourceType={resourceType} /> ); + case PamResourceType.OracleDB: + return ( + + ); case PamResourceType.MongoDB: return ( { return ( ); + case PamResourceType.OracleDB: + return ( + + ); case PamResourceType.MongoDB: return ( diff --git a/frontend/src/pages/pam/PamResourceByIDPage/components/PamResourceConnectionSection.tsx b/frontend/src/pages/pam/PamResourceByIDPage/components/PamResourceConnectionSection.tsx index 6e44f88e509..7813b0bc0cd 100644 --- a/frontend/src/pages/pam/PamResourceByIDPage/components/PamResourceConnectionSection.tsx +++ b/frontend/src/pages/pam/PamResourceByIDPage/components/PamResourceConnectionSection.tsx @@ -179,6 +179,7 @@ const ConnectionDetailsContent = ({ resource }: Props) => { case PamResourceType.Postgres: case PamResourceType.MySQL: case PamResourceType.MsSQL: + case PamResourceType.OracleDB: return ; case PamResourceType.MongoDB: return ; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/OracleDBResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/OracleDBResourceForm.tsx new file mode 100644 index 00000000000..a8056068c4a --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/OracleDBResourceForm.tsx @@ -0,0 +1,94 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, SheetFooter } from "@app/components/v3"; +import { PamResourceType, TOracleDBResource } from "@app/hooks/api/pam"; + +import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas"; +import { SqlResourceFields } from "./shared/SqlResourceFields"; +import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields"; +import { MetadataFields } from "./MetadataFields"; + +const OracleResourceSchema = BaseSqlResourceSchema.extend({ + database: z + .string() + .trim() + .min(1, "Service name required") + .max(128) + .regex( + /^[a-zA-Z][a-zA-Z0-9_.#$]*$/, + "Must start with a letter and contain only letters, digits, underscores, dots, # or $" + ) +}); + +type Props = { + resource?: TOracleDBResource; + onSubmit: (formData: FormData) => Promise; + closeSheet: () => void; +}; + +const formSchema = genericResourceFieldsSchema.extend({ + resourceType: z.literal(PamResourceType.OracleDB), + connectionDetails: OracleResourceSchema +}); + +type FormData = z.infer; + +export const OracleDBResourceForm = ({ resource, onSubmit, closeSheet }: Props) => { + const isUpdate = Boolean(resource); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource + ? { + ...resource, + gateway: resource.gatewayId ? { id: resource.gatewayId, name: "" } : undefined + } + : { + resourceType: PamResourceType.OracleDB, + gateway: undefined, + connectionDetails: { + host: "", + port: 1521, + database: "ORCLCDB", + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
onSubmit(data as FormData))} + className="flex flex-1 flex-col overflow-hidden" + > +
+ + + +
+ + + + +
+
+ ); +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx index 7d84cbe1bb9..b48cab935bc 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx @@ -14,6 +14,7 @@ import { KubernetesResourceForm } from "./KubernetesResourceForm"; import { MongoDBResourceForm } from "./MongoDBResourceForm"; import { MsSQLResourceForm } from "./MsSQLResourceForm"; import { MySQLResourceForm } from "./MySQLResourceForm"; +import { OracleDBResourceForm } from "./OracleDBResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm"; import { RedisResourceForm } from "./RedisResourceForm"; import { SSHResourceForm } from "./SSHResourceForm"; @@ -66,6 +67,8 @@ const CreateForm = ({ resourceType, closeSheet, projectId }: CreateFormProps) => return ; case PamResourceType.MsSQL: return ; + case PamResourceType.OracleDB: + return ; case PamResourceType.MongoDB: return ; case PamResourceType.Redis: @@ -119,6 +122,10 @@ const UpdateForm = ({ resource, closeSheet }: UpdateFormProps) => { return ; case PamResourceType.MsSQL: return ; + case PamResourceType.OracleDB: + return ( + + ); case PamResourceType.MongoDB: return ( diff --git a/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx b/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx index f2d1fef604e..bdc4cb06be9 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx @@ -15,7 +15,6 @@ type Props = { }; const COMING_SOON_RESOURCES = [ - { name: "OracleDB", resource: PamResourceType.OracleDB }, { name: "SQLite", resource: PamResourceType.SQLite }, { name: "Cassandra", resource: PamResourceType.Cassandra }, { name: "CockroachDB", resource: PamResourceType.CockroachDB }, diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx index d470c398890..0be0e40111a 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx @@ -42,7 +42,8 @@ export const PamSessionLogsSection = ({ session, scrollToLogIndex }: Props) => { session.resourceType === PamResourceType.MySQL || session.resourceType === PamResourceType.MsSQL || session.resourceType === PamResourceType.MongoDB || - session.resourceType === PamResourceType.Redis; + session.resourceType === PamResourceType.Redis || + session.resourceType === PamResourceType.OracleDB; const isHttpSession = session.resourceType === PamResourceType.Kubernetes; const isAwsIamSession = session.resourceType === PamResourceType.AwsIam; const hasLogs = logs.length > 0;