Skip to content

Commit

Permalink
feat(api): Secret rotation (#652)
Browse files Browse the repository at this point in the history
Co-authored-by: Rajdip Bhattacharya <[email protected]>
  • Loading branch information
csehatt741 and rajdip-b authored Jan 24, 2025
1 parent 443f8d4 commit ad9a808
Show file tree
Hide file tree
Showing 7 changed files with 337 additions and 13 deletions.
39 changes: 39 additions & 0 deletions apps/api/src/common/secret.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { generateSecretValue } from './secret'

describe('generateSecretValue', () => {
test('should generate a string of exactly 20 characters', () => {
const secret = generateSecretValue()
expect(secret).toHaveLength(20)
})

test('should include at least one digit', () => {
const secret = generateSecretValue()
expect(secret).toMatch(/\d/)
})

test('should include at least one lowercase letter', () => {
const secret = generateSecretValue()
expect(secret).toMatch(/[a-z]/)
})

test('should include at least one uppercase letter', () => {
const secret = generateSecretValue()
expect(secret).toMatch(/[A-Z]/)
})

test('should include at least one special character', () => {
const secret = generateSecretValue()
expect(secret).toMatch(/[!@#$%^&*]/)
})

test('should only include allowed characters', () => {
const secret = generateSecretValue()
expect(secret).toMatch(/^[0-9a-zA-Z!@#$%^&*]{20}$/)
})

test('should generate different values for consecutive calls', () => {
const secret1 = generateSecretValue()
const secret2 = generateSecretValue()
expect(secret1).not.toBe(secret2)
})
})
36 changes: 36 additions & 0 deletions apps/api/src/common/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,39 @@ export function getSecretWithValues(
values
}
}

export function generateSecretValue(): string {
const length = 20
const digits = '0123456789'
const lowercaseChars = 'abcdefghijklmnopqrstuvwxyz'
const uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const specialChars = '!@#$%^&*'
const allChars = digits + lowercaseChars + uppercaseChars + specialChars

const getRandomIndex = (max: number): number => {
const randomValues = new Uint8Array(1)
crypto.getRandomValues(randomValues)
return randomValues[0] % max
}

// Ensure at least one character from each required set is included
const result = [
lowercaseChars[getRandomIndex(lowercaseChars.length)],
uppercaseChars[getRandomIndex(uppercaseChars.length)],
digits[getRandomIndex(digits.length)],
specialChars[getRandomIndex(specialChars.length)]
]

// Fill the rest of the string to meet the minimum length
while (result.length < length) {
result.push(allChars[getRandomIndex(allChars.length)])
}

// Shuffle the result to randomize the order
for (let i = result.length - 1; i > 0; i--) {
const j = getRandomIndex(i + 1)
;[result[i], result[j]] = [result[j], result[i]]
}

return result.join('')
}
2 changes: 1 addition & 1 deletion apps/api/src/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const excludeFields = <T, K extends keyof T>(
* @param hours The number of hours to add to the current date
* @returns The new date with the given number of hours added, or undefined if the hours is 'never'
*/
export const addHoursToDate = (hours?: string): Date | undefined => {
export const addHoursToDate = (hours?: string | number): Date | undefined => {
if (!hours || hours === 'never') return undefined

const date = new Date()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Secret" ADD COLUMN "rotateAfter" INTEGER;
17 changes: 9 additions & 8 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -371,14 +371,15 @@ model SecretVersion {
}

model Secret {
id String @id @default(cuid())
name String
slug String @unique
versions SecretVersion[] // Stores the versions of the secret
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rotateAt DateTime?
note String?
id String @id @default(cuid())
name String
slug String @unique
versions SecretVersion[] // Stores the versions of the secret
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rotateAt DateTime?
rotateAfter Int?
note String?
lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull)
lastUpdatedById String?
Expand Down
110 changes: 110 additions & 0 deletions apps/api/src/secret/secret.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ describe('Secret Controller Tests', () => {
},
createdAt: secret1.createdAt.toISOString(),
updatedAt: secret1.updatedAt.toISOString(),
rotateAfter: secret1.rotateAfter,
rotateAt: secret1.rotateAt.toISOString()
})
expect(values.length).toBe(1)
Expand Down Expand Up @@ -686,6 +687,7 @@ describe('Secret Controller Tests', () => {
expect(response.json().items.length).toBe(1)

const { secret, values } = response.json().items[0]

expect(secret).toStrictEqual({
id: secret1.id,
name: secret1.name,
Expand All @@ -699,6 +701,7 @@ describe('Secret Controller Tests', () => {
},
createdAt: secret1.createdAt.toISOString(),
updatedAt: expect.any(String),
rotateAfter: secret1.rotateAfter,
rotateAt: secret1.rotateAt.toISOString()
})
expect(values.length).toBe(1)
Expand Down Expand Up @@ -752,6 +755,7 @@ describe('Secret Controller Tests', () => {
},
createdAt: secret1.createdAt.toISOString(),
updatedAt: secret1.updatedAt.toISOString(),
rotateAfter: secret1.rotateAfter,
rotateAt: secret1.rotateAt.toISOString()
})
expect(values.length).toBe(1)
Expand Down Expand Up @@ -1114,4 +1118,110 @@ describe('Secret Controller Tests', () => {
expect(response.statusCode).toBe(401)
})
})

describe('Rotate Secrets Tests', () => {
it('should have not created a new secret version when there is no rotation defined', async () => {
const secretWithoutRotation = (
await secretService.createSecret(
user1,
{
name: 'Secret',
note: 'Secret note',
rotateAfter: 'never',
entries: [
{
environmentSlug: environment1.slug,
value: 'Secret value'
}
]
},
project1.slug
)
).secret as Secret

await secretService.rotateSecrets()

const secretVersion = await prisma.secretVersion.findFirst({
where: {
secretId: secretWithoutRotation.id,
environmentId: environment1.id
},
orderBy: {
version: 'desc'
},
take: 1
})

expect(secretVersion).toBeDefined()
expect(secretVersion.version).toBe(1)
expect(secretVersion.environmentId).toBe(environment1.id)
})

it('should have not created a new secret version when rotation is not due', async () => {
await secretService.rotateSecrets()

const secretVersion = await prisma.secretVersion.findFirst({
where: {
secretId: secret1.id,
environmentId: environment1.id
},
orderBy: {
version: 'desc'
},
take: 1
})

expect(secretVersion).toBeDefined()
expect(secretVersion.version).toBe(1)
expect(secretVersion.environmentId).toBe(environment1.id)
})

it('should have created a new secret version when rotation is due', async () => {
const currentTime = new Date()

currentTime.setHours(currentTime.getHours() + secret1.rotateAfter)

await secretService.rotateSecrets(currentTime)

const secretVersion = await prisma.secretVersion.findFirst({
where: {
secretId: secret1.id,
environmentId: environment1.id
},
orderBy: {
version: 'desc'
},
take: 1
})

expect(secretVersion).toBeDefined()
expect(secretVersion.version).toBe(2)
expect(secretVersion.environmentId).toBe(environment1.id)
})

it('should have created a SECRET_UPDATED event when rotation is due', async () => {
const currentTime = new Date()

currentTime.setHours(currentTime.getHours() + secret1.rotateAfter)

await secretService.rotateSecrets(currentTime)

const events = await fetchEvents(
eventService,
user1,
workspace1.slug,
EventSource.SECRET
)

const event = events.items[0]

expect(event.source).toBe(EventSource.SECRET)
expect(event.triggerer).toBe(EventTriggerer.SYSTEM)
expect(event.severity).toBe(EventSeverity.INFO)
expect(event.type).toBe(EventType.SECRET_UPDATED)
expect(event.workspaceId).toBe(workspace1.id)
expect(event.itemId).toBe(secret1.id)
expect(event.title).toBe('Secret rotated')
})
})
})
Loading

0 comments on commit ad9a808

Please sign in to comment.