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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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)

Comment thread
msukkari marked this conversation as resolved.
## [4.5.1] - 2025-07-14

Expand Down
3 changes: 1 addition & 2 deletions demo-site-config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,7 +238,6 @@
}
},
"settings": {
"reindexIntervalMs": 86400000, // 24 hours
"enablePublicAccess": true
"reindexIntervalMs": 86400000 // 24 hours
}
Comment thread
msukkari marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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.
Comment thread
msukkari marked this conversation as resolved.

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
ContributorAuthor

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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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