Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- Fixed typos in UI, docs, code [#369](https://github.com/sourcebot-dev/sourcebot/pull/369)
- Add anonymous access option to core and deprecate the `enablePublicAccess` config setting. [#385](https://github.com/sourcebot-dev/sourcebot/pull/385)

## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"pages": [
"docs/configuration/auth/overview",
"docs/configuration/auth/providers",
"docs/configuration/auth/inviting-members",
"docs/configuration/auth/access-settings",
"docs/configuration/auth/roles-and-permissions",
"docs/configuration/auth/faq"
]
Expand Down
40 changes: 40 additions & 0 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: Access Settings
sidebarTitle: Access settings
---

There are various settings to control how users access your Sourcebot deployment.

# Anonymous access

<Note>Anonymous access cannot be enabled if you have an enterprise license. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact).</Note>

By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access.

This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable.

When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role.

# Member Approval

By default, Sourcebot requires new members to be approved by the owner of the deployment. This section explains how approvals work and how
to configure this behavior.

### Configuration
Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**:

![Member Approval Toggle](/images/member_approval_toggle.png)

### Managing Requests

If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment
until this request is approved by the owner.

The owner can see and manage all pending join requests by navigating to **Settings -> Members**.

## Invite link

If member approval is required, an owner of the deployment can enable an invite link. When enabled, users
can use this invite link to register and be automatically added to the organization without approval:

![Invite Link Toggle](/images/invite_link_toggle.png)
30 changes: 0 additions & 30 deletions docs/docs/configuration/auth/inviting-members.mdx

This file was deleted.

3 changes: 2 additions & 1 deletion docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ Each member has a role which defines their permissions within an organization:
| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, but may not manage the organization or its connections. |
| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, as well as view the organizations configuration and member list. However, they cannot modify this configuration or invite new members. |
| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. |
1 change: 1 addition & 0 deletions docs/docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | <p>The data directory for the default Postgres database.</p> |
| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | <p>Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.</p><p>If you'd like to use a non-default schema, you can provide it as a parameter in the database url </p> |
| `EMAIL_FROM_ADDRESS` | `-` | <p>The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.</p> |
| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` | <p>When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled</p>
| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | <p>The data directory for the default Redis instance.</p> |
| `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> |
| `REDIS_REMOVE_ON_COMPLETE` | `0` | <p>Controls how many completed jobs are allowed to remain in Redis queues</p> |
Expand Down
17 changes: 0 additions & 17 deletions docs/docs/deployment-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,6 @@ import SupportedPlatforms from '/snippets/platform-support.mdx'
The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker.


## Walkthrough video
---

Watch this quick walkthrough video to learn how to deploy Sourcebot using Docker.

<iframe
src="https://youtube.com/embed/TPQh0z7Qcjg"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
className="aspect-video w-full"
></iframe>

## Step-by-step guide
---

<Note>Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).</Note>

<Steps>
Expand Down
6 changes: 4 additions & 2 deletions docs/snippets/schemas/v3/index.schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
},
"enablePublicAccess": {
"type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
}
},
Expand Down Expand Up @@ -180,7 +181,8 @@
},
"enablePublicAccess": {
"type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ export const DEFAULT_SETTINGS: Settings = {
maxRepoGarbageCollectionJobConcurrency: 8,
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
enablePublicAccess: false,
enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
}
2 changes: 1 addition & 1 deletion packages/schemas/src/v2/index.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2258,4 +2258,4 @@ const schema = {
},
"additionalProperties": false
} as const;
export { schema as indexSchema };
export { schema as indexSchema };
6 changes: 4 additions & 2 deletions packages/schemas/src/v3/index.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const schema = {
},
"enablePublicAccess": {
"type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
}
},
Expand Down Expand Up @@ -179,7 +180,8 @@ const schema = {
},
"enablePublicAccess": {
"type": "boolean",
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
"deprecated": true,
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
"default": false
}
},
Expand Down
3 changes: 2 additions & 1 deletion packages/schemas/src/v3/index.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export interface Settings {
*/
repoIndexTimeoutMs?: number;
/**
* [Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.
* @deprecated
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
*/
enablePublicAccess?: boolean;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type Plan = keyof typeof planLabels;
const entitlements = [
"search-contexts",
"billing",
"public-access",
"anonymous-access",
"multi-tenancy",
"sso",
"code-nav",
Expand All @@ -43,12 +43,12 @@ const entitlements = [
export type Entitlement = (typeof entitlements)[number];

const entitlementsByPlan: Record<Plan, Entitlement[]> = {
oss: [],
oss: ["anonymous-access"],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit", "analytics"],
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"],
// Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
} as const;


Expand Down
91 changes: 76 additions & 15 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/bill
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
import { getPlan, hasEntitlement } from "@sourcebot/shared";
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger";
import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { getOrgMetadata } from "@/lib/utils";
import { getOrgFromDomain } from "./data/org";

const ajv = new Ajv({
validateFormats: false,
Expand All @@ -62,13 +63,13 @@ export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> =>
}
}

export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not asking for us to do this in this PR, but I feel like this allowAnonymous flag is a bit of a code-smell. I'm pretty sure that in all scenarios where this is set to true, the minimum organization role will be GUEST anyways. If a organization has anonymous access enabled, then the minimum role remains at GUEST. If not, then the minimum role for all actions is raised to MEMBER.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it more, we could probably eliminate the need for the concept of our sentinel guestUser entirely, since everywhere where a user is actually required (i.e., besides verifying they are a member of the organization), the role would be MEMBER or above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it definitely isn't ideal, but the reason this is needed rn is how we're chaining withAuth and withOrgMembership. The withOrgMembership expects withAuth to pass, so we're required to create the sentinel Guest user because it expects a userId. We need to pass in the allowAnonymous flag to know whether or not to return a Guest user or nothing

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. We should probably just combine withOrgMembership and withAuth into a single middleware function that handles both the authentication and authorization checks.

const session = await auth();

if (!session) {
// First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not,
// then this is an invalid unauthed request and we return a 401.
const publicAccessEnabled = await getPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN);
const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN);
if (apiKey) {
const apiKeyOrError = await verifyApiKey(apiKey);
if (isServiceError(apiKeyOrError)) {
Expand Down Expand Up @@ -98,18 +99,17 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde

return fn(user.id, apiKeyOrError.apiKey.hash);
} else if (
env.SOURCEBOT_TENANCY_MODE === 'single' &&
allowSingleTenantUnauthedAccess &&
!isServiceError(publicAccessEnabled) &&
publicAccessEnabled
allowAnonymousAccess &&
!isServiceError(anonymousAccessEnabled) &&
anonymousAccessEnabled
) {
if (!hasEntitlement("public-access")) {
if (!hasEntitlement("anonymous-access")) {
const plan = getPlan();
logger.error(`Public access isn't supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return notAuthenticated();
}

// To support unauthed access a guest user is created in initialize.ts, which we return here
// To support anonymous access a guest user is created in initialize.ts, which we return here
return fn(SOURCEBOT_GUEST_USER_ID, undefined);
}
return notAuthenticated();
Expand Down Expand Up @@ -672,7 +672,7 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));

export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
Expand Down Expand Up @@ -734,7 +734,7 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));

export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
Expand Down Expand Up @@ -933,7 +933,7 @@ export const getCurrentUserRole = async (domain: string): Promise<OrgRole | Serv
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ userRole }) => {
return userRole;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));

export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
Expand Down Expand Up @@ -1863,7 +1863,7 @@ export const getSearchContexts = async (domain: string) => sew(() =>
name: context.name,
description: context.description ?? undefined,
}));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));

export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => {
Expand Down Expand Up @@ -1934,7 +1934,68 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
return notFound();
}
}, /* minRequiredRole = */ OrgRole.GUEST);
}, /* allowSingleTenantUnauthedAccess = */ true);
}, /* allowAnonymousAccess = */ true);
});

export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
const org = await getOrgFromDomain(domain);
if (!org) {
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "Organization not found",
} satisfies ServiceError;
}

// If no metadata is set we don't try to parse it since it'll result in a parse error
if (org.metadata === null) {
return false;
}

const orgMetadata = getOrgMetadata(org);
if (!orgMetadata) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_ORG_METADATA,
message: "Invalid organization metadata",
} satisfies ServiceError;
}

return !!orgMetadata.anonymousAccessEnabled;
});

export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
return await withAuth(async (userId) => {
return await withOrgMembership(userId, domain, async ({ org }) => {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (!hasAnonymousAccessEntitlement) {
const plan = getPlan();
console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`);
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Anonymous access is not supported in your current plan",
} satisfies ServiceError;
}

const currentMetadata = getOrgMetadata(org);
const mergedMetadata = {
...(currentMetadata ?? {}),
anonymousAccessEnabled: enabled,
};

await prisma.org.update({
where: {
id: org.id,
},
data: {
metadata: mergedMetadata,
},
});

return true;
}, /* minRequiredRole = */ OrgRole.OWNER);
});
});

////// Helpers ///////
Expand Down
Loading