Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4ef3df4
deps added, env updated
neSpecc Dec 20, 2025
3ecdd48
bootstrap module
neSpecc Dec 20, 2025
73fa3b9
models updated
neSpecc Dec 20, 2025
025fdc1
update branch
neSpecc Dec 24, 2025
223a946
tests for model and factory method added
neSpecc Dec 24, 2025
c7f9c29
rm redunant test cases
neSpecc Dec 24, 2025
5892ea0
Update .nvmrc
neSpecc Dec 24, 2025
1ca5fb8
Refactor SAML validation logic and add unit tests
neSpecc Dec 24, 2025
77d55e9
rm try-catch from tests
neSpecc Dec 24, 2025
61fea8e
Refactor SAML response validation to use node-saml
neSpecc Dec 24, 2025
1369b2c
Implement SAML AuthnRequest generation and tests
neSpecc Dec 24, 2025
0725454
SamlStateStore implemetation
neSpecc Dec 28, 2025
1963a59
Implement SAML SSO controller and tests
neSpecc Jan 7, 2026
76232df
Add SSO config support with admin-only GraphQL directive
neSpecc Jan 7, 2026
e9a6848
Add dynamic Node version and improve SAML SSO error handling
neSpecc Jan 7, 2026
0ea60b7
Update build-and-push-docker-image.yml
neSpecc Jan 7, 2026
1e9695b
Update build-and-push-docker-image.yml
neSpecc Jan 7, 2026
6b3d58c
Update mongodb.ts
neSpecc Jan 7, 2026
6cacb3a
Update controller.test.ts
neSpecc Jan 7, 2026
19d03f6
Add public SSO workspace info query
neSpecc Jan 7, 2026
65c22e3
Update workspace.js
neSpecc Jan 7, 2026
154f59e
Enforce SSO login and refactor SSO config update
neSpecc Jan 12, 2026
c384e71
add logs to the sso controller
neSpecc Jan 12, 2026
fbb0afc
Shorten refresh token expiry for enforced SSO users
neSpecc Jan 12, 2026
de004e3
Create sso.test.ts
neSpecc Jan 12, 2026
6e3d722
fixes for sso
neSpecc Jan 14, 2026
fc57f58
integration tests
neSpecc Jan 15, 2026
cf3c050
Bump version up to 1.2.33
github-actions[bot] Jan 15, 2026
7190108
Update package.json
neSpecc Jan 15, 2026
1080020
lint
neSpecc Jan 15, 2026
73a89e6
fix tests
neSpecc Jan 15, 2026
de08345
Add pluggable SAML state store with Redis and memory support
neSpecc Jan 15, 2026
26b9d2f
fix unti tests
neSpecc Jan 15, 2026
d95d526
fix integration tests
neSpecc Jan 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_BUCKET_NAME=
AWS_S3_BUCKET_BASE_URL=
AWS_S3_BUCKET_ENDPOINT=

# SSO Service Provider Entity ID
# Unique identifier for Hawk in SAML IdP configuration
SSO_SP_ENTITY_ID=urn:hawk:tracker:saml
8 changes: 8 additions & 0 deletions .github/workflows/build-and-push-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,19 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

- name: Read Node.js version from .nvmrc
id: node_version
run: |
NODE_VERSION=$(cat .nvmrc | tr -d 'v')
echo "version=${NODE_VERSION}" >> $GITHUB_OUTPUT

- name: Build and push image
uses: docker/build-push-action@v3
with:
context: .
file: docker/Dockerfile.prod
build-args: |
NODE_VERSION=${{ steps.node_version.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: ${{ github.ref == 'refs/heads/stage' || github.ref == 'refs/heads/prod' || startsWith(github.ref, 'refs/tags/v') }}
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v22.12.0
v24.11.1
38 changes: 37 additions & 1 deletion docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ services:
- ./:/usr/src/app
- /usr/src/app/node_modules
- ./test/integration/api.env:/usr/src/app/.env
- ../keycloak:/keycloak:ro
depends_on:
- mongodb
- rabbitmq
- keycloak
# - accounting
stdin_open: true
tty: true
Expand All @@ -32,10 +34,20 @@ services:
condition: service_healthy
api:
condition: service_started
command: dockerize -wait http://api:4000/.well-known/apollo/server-health -timeout 30s yarn jest --config=./test/integration/jest.config.js --runInBand test/integration
keycloak:
condition: service_healthy
environment:
- KEYCLOAK_URL=http://keycloak:8180
entrypoint: ["/bin/bash", "-c"]
command:
- |
dockerize -wait http://api:4000/.well-known/apollo/server-health -timeout 30s -wait http://keycloak:8180/health/ready -timeout 60s &&
/keycloak/setup.sh &&
yarn jest --config=./test/integration/jest.config.js --runInBand test/integration
volumes:
- ./:/usr/src/app
- /usr/src/app/node_modules
- ../keycloak:/keycloak:ro

rabbitmq:
image: rabbitmq:3-management
Expand All @@ -52,6 +64,29 @@ services:
timeout: 3s
retries: 5

keycloak:
image: quay.io/keycloak/keycloak:23.0
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
- KC_HTTP_PORT=8180
- KC_HOSTNAME_STRICT=false
- KC_HOSTNAME_STRICT_HTTPS=false
- KC_HTTP_ENABLED=true
- KC_HEALTH_ENABLED=true
ports:
- 8180:8180
command:
- start-dev
volumes:
- keycloak-test-data:/opt/keycloak/data
- ../keycloak:/opt/keycloak/config
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
interval: 10s
timeout: 5s
retries: 10

# accounting:
# image: codexteamuser/codex-accounting:prod
# env_file:
Expand All @@ -61,3 +96,4 @@ services:

volumes:
mongodata-test:
keycloak-test-data:
7 changes: 4 additions & 3 deletions docker/Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM node:22-alpine as builder
ARG NODE_VERSION=24.11.1
FROM node:${NODE_VERSION}-alpine as builder

WORKDIR /usr/src/app
RUN apk add --no-cache git gcc g++ python3 make musl-dev
Expand All @@ -7,11 +8,11 @@ COPY package.json yarn.lock ./

RUN yarn install

FROM node:22-alpine
FROM node:${NODE_VERSION}-alpine

WORKDIR /usr/src/app

RUN apk add --no-cache openssl
RUN apk add --no-cache openssl bash curl

ENV DOCKERIZE_VERSION v0.6.1
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
Expand Down
5 changes: 3 additions & 2 deletions docker/Dockerfile.prod
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM node:22-alpine as builder
ARG NODE_VERSION=24.11.1
FROM node:${NODE_VERSION}-alpine as builder

WORKDIR /usr/src/app
RUN apk add --no-cache git gcc g++ python3 make musl-dev
Expand All @@ -11,7 +12,7 @@ COPY . .

RUN yarn build

FROM node:22-alpine
FROM node:${NODE_VERSION}-alpine

WORKDIR /usr/src/app

Expand Down
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ module.exports = {
* TypeScript support
*/
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'test/tsconfig.json',
}],
},

/**
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.32",
"version": "1.3.0",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand All @@ -23,6 +23,7 @@
"@shelf/jest-mongodb": "^6.0.2",
"@swc/core": "^1.3.0",
"@types/jest": "^26.0.8",
"@types/xml2js": "^0.4.14",
"eslint": "^6.7.2",
"eslint-config-codex": "1.2.4",
"eslint-plugin-import": "^2.19.1",
Expand All @@ -32,7 +33,8 @@
"redis-mock": "^0.56.3",
"ts-jest": "^26.1.4",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
"typescript": "^4.7.4",
"xml2js": "^0.6.2"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.64",
Expand All @@ -41,8 +43,9 @@
"@graphql-tools/schema": "^8.5.1",
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.1.1",
"@hawk.so/types": "^0.1.37",
"@hawk.so/types": "^0.4.2",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
"@types/amqp-connection-manager": "^2.0.4",
"@types/debug": "^4.1.5",
"@types/escape-html": "^1.0.0",
Expand Down
101 changes: 101 additions & 0 deletions src/directives/definedOnlyForAdmins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils';
import { ResolverContextWithUser, UnknownGraphQLResolverResult } from '../types/graphql';
import { ForbiddenError, UserInputError } from 'apollo-server-express';
Comment thread
neSpecc marked this conversation as resolved.
Outdated
import WorkspaceModel from '../models/workspace';

/**
* Check if user is admin of workspace
* @param context - resolver context
* @param workspaceId - workspace id to check
* @returns true if user is admin, false otherwise
*/
async function isUserAdminOfWorkspace(context: ResolverContextWithUser, workspaceId: string): Promise<boolean> {
try {
const workspace = await context.factories.workspacesFactory.findById(workspaceId);

if (!workspace) {
return false;
}

const member = await workspace.getMemberInfo(context.user.id);

if (!member || WorkspaceModel.isPendingMember(member)) {
return false;
}

return member.isAdmin || false;
} catch {
return false;
}
}

/**
* Defines directive for fields that are only defined for admins
* Returns null for non-admin users instead of throwing error
*
* Works with object fields where parent object has _id field (workspace id)
*
* Usage:
* type Workspace {
* sso: WorkspaceSsoConfig @definedOnlyForAdmins
* }
*/
export default function definedOnlyForAdminsDirective(directiveName = 'definedOnlyForAdmins') {
return {
definedOnlyForAdminsDirectiveTypeDefs: `
"""
Field is only defined for admins. Returns null for non-admin users.
Works with object fields where parent object has _id field (workspace id).
"""
directive @${directiveName} on FIELD_DEFINITION
`,
definedOnlyForAdminsDirectiveTransformer: (schema: GraphQLSchema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const definedOnlyForAdminsDirective = getDirective(schema, fieldConfig, directiveName)?.[0];

if (definedOnlyForAdminsDirective) {
const {
resolve = defaultFieldResolver,
} = fieldConfig;

/**
* New field resolver that checks admin rights
* @param resolverArgs - default GraphQL resolver args
*/
fieldConfig.resolve = async (...resolverArgs): UnknownGraphQLResolverResult => {
const [parent, , context] = resolverArgs;

/**
* Get workspace ID from parent object
* Parent should have _id field (workspace)
*/
if (!parent || !parent._id) {
return null;
}

const workspaceId = parent._id.toString();

/**
* Check if user is admin
*/
const isAdmin = await isUserAdminOfWorkspace(context, workspaceId);

if (!isAdmin) {
return null;
}

/**
* Call original resolver
*/
return resolve(...resolverArgs);
};
}

return fieldConfig;
},
}),
};
}

Check failure on line 101 in src/directives/definedOnlyForAdmins.ts

View workflow job for this annotation

GitHub Actions / ESlint

Too many blank lines at the end of file. Max of 0 allowed
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './
import { requestLogger } from './utils/logger';
import ReleasesFactory from './models/releasesFactory';
import RedisHelper from './redisHelper';
import { appendSsoRoutes } from './sso';

/**
* Option to enable playground
Expand Down Expand Up @@ -246,6 +247,22 @@ class HawkAPI {

await redis.initialize();

/**
* Setup shared factories for SSO routes
* SSO endpoints don't require per-request DataLoaders isolation,
* so we can reuse the same factories instance
* Created here to avoid duplication with createContext
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ssoDataLoaders = new DataLoaders(mongo.databases.hawk!);
const ssoFactories = HawkAPI.setupFactories(ssoDataLoaders);

/**
* Append SSO routes to Express app using shared factories
* Note: This must be called after database connections are established
*/
appendSsoRoutes(this.app, ssoFactories);

await this.server.start();
this.app.use(graphqlUploadExpress());
this.server.applyMiddleware({ app: this.app });
Expand Down
10 changes: 10 additions & 0 deletions src/metrics/mongodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ function logCommandFailed(event: any): void {
* @param client - MongoDB client to monitor
*/
export function setupMongoMetrics(client: MongoClient): void {
/**
* Skip setup in test environment
*/
if (
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'e2e'
) {
return;
}

client.on('commandStarted', (event) => {
storeCommandInfo(event);

Expand Down
Loading
Loading