@apso/cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +510 -1
- package/dist/commands/server/new.js +1 -1
- package/dist/commands/server/scaffold.js +19 -10
- package/dist/lib/apsorc-parser.d.ts +3 -0
- package/dist/lib/apsorc-parser.js +11 -6
- package/dist/lib/controller.js +3 -1
- package/dist/lib/dto.js +9 -9
- package/dist/lib/entity.js +6 -5
- package/dist/lib/guards.d.ts +4 -3
- package/dist/lib/guards.js +64 -25
- package/dist/lib/module.js +3 -1
- package/dist/lib/templates/guards/auth.guard.eta +228 -0
- package/dist/lib/templates/guards/guards.module.eta +73 -13
- package/dist/lib/templates/guards/index.eta +18 -0
- package/dist/lib/templates/guards/scope.guard.eta +12 -4
- package/dist/lib/types/auth.d.ts +203 -0
- package/dist/lib/types/auth.js +60 -0
- package/dist/lib/types/entity.d.ts +2 -2
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +8 -0
- package/dist/lib/types/relationship.d.ts +1 -1
- package/dist/lib/utils/casing.js +5 -5
- package/dist/lib/utils/field.js +2 -1
- package/dist/lib/utils/file-system.js +1 -1
- package/dist/lib/utils/relationships/parse-v1.js +2 -1
- package/dist/lib/utils/relationships/parse.js +117 -32
- package/dist/lib/utils/relationships/parse.spec.js +168 -141
- package/dist/test-nested-relationships.js +10 -10
- package/dist/tests/templates/entities/types/array-template.spec.js +45 -45
- package/dist/tests/templates/entities/types/bigint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/boolean-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/bytea-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/char-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/date-template.spec.js +29 -29
- package/dist/tests/templates/entities/types/decimal-field-validation.spec.js +46 -46
- package/dist/tests/templates/entities/types/decimal-template.spec.js +46 -46
- package/dist/tests/templates/entities/types/double-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/enum-template.spec.js +42 -42
- package/dist/tests/templates/entities/types/float-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/geometry-template.spec.js +31 -31
- package/dist/tests/templates/entities/types/inet-template.spec.js +36 -36
- package/dist/tests/templates/entities/types/int4range-template.spec.js +45 -33
- package/dist/tests/templates/entities/types/integer-template.spec.js +40 -40
- package/dist/tests/templates/entities/types/interval-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/json-plain-template.spec.js +27 -27
- package/dist/tests/templates/entities/types/json-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/jsonb-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/money-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/numeric-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/point-template.spec.js +37 -31
- package/dist/tests/templates/entities/types/polygon-template.spec.js +43 -31
- package/dist/tests/templates/entities/types/real-template.spec.js +34 -34
- package/dist/tests/templates/entities/types/serial-template.spec.js +62 -62
- package/dist/tests/templates/entities/types/smallint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/string-template.spec.js +59 -59
- package/dist/tests/templates/entities/types/text-template.spec.js +37 -37
- package/dist/tests/templates/entities/types/time-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/timestamp-template.spec.js +39 -39
- package/dist/tests/templates/entities/types/timetz-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/tsvector-template.spec.js +28 -28
- package/dist/tests/templates/entities/types/uuid-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/varchar-template.spec.js +59 -56
- package/dist/tests/templates/entities/types/xml-template.spec.js +28 -28
- package/dist/tests/templates/entities/utils/template-test-utils.d.ts +1 -1
- package/dist/tests/templates/entities/utils/template-test-utils.js +3 -3
- package/dist/tests/templates/entities/uuid-entity-template.spec.js +138 -138
- package/dist/tests/utils/relationship-deduplication.spec.js +11 -11
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,9 @@ npm run start:dev
|
|
|
35
35
|
- [Populating an .apsorc File](#populating-an-apsorc-file)
|
|
36
36
|
- [Auto-Generated Code Reference](#auto-generated-code-reference)
|
|
37
37
|
- [Relationships](#relationships)
|
|
38
|
+
- [Authentication (Bring Your Own Auth)](#authentication-bring-your-own-auth)
|
|
38
39
|
- [Data Scoping (Multi-Tenant Isolation)](#data-scoping-multi-tenant-isolation)
|
|
40
|
+
- [Authentication + Scoping: Working Together](#authentication--scoping-working-together)
|
|
39
41
|
- [Schema Reference](#schema-reference)
|
|
40
42
|
- [Debugging](#debugging)
|
|
41
43
|
- [Commands](#commands)
|
|
@@ -746,9 +748,354 @@ Refer to the [example v2 file](#example-apsorc-v2-file) for usage.
|
|
|
746
748
|
> - Avoid deep nesting and duplicate definitions.
|
|
747
749
|
> - Always inspect generated code and test your build after scaffolding.
|
|
748
750
|
|
|
751
|
+
## Authentication (Bring Your Own Auth)
|
|
752
|
+
|
|
753
|
+
Apso provides flexible, provider-agnostic authentication that generates NestJS guards from your `.apsorc` configuration. Unlike monolithic platforms that force vendor lock-in through proprietary auth systems, Apso embraces **code ownership** - you choose your auth provider, and you own the generated code.
|
|
754
|
+
|
|
755
|
+
### Philosophy: Why Bring Your Own Auth Matters
|
|
756
|
+
|
|
757
|
+
Traditional BaaS platforms like Supabase and Firebase provide authentication as a core feature, but this creates dependency:
|
|
758
|
+
- Your user data lives in their systems
|
|
759
|
+
- Migrating away requires rewriting auth logic
|
|
760
|
+
- You're bound to their pricing, features, and roadmap
|
|
761
|
+
|
|
762
|
+
**Apso takes a different approach:**
|
|
763
|
+
- **Provider flexibility** - Use Better Auth, Auth0, Cognito, Clerk, or custom solutions
|
|
764
|
+
- **Code ownership** - Generated guards are standard NestJS code you can inspect, modify, and extend
|
|
765
|
+
- **Zero lock-in** - Switch providers by changing configuration, not rewriting code
|
|
766
|
+
- **Normalized interface** - All providers produce the same `AuthContext` consumed by scoping and RBAC
|
|
767
|
+
|
|
768
|
+
This philosophy ensures your authentication layer is **timeless** - it grows with your needs and migrates with your stack.
|
|
769
|
+
|
|
770
|
+
### Supported Authentication Providers
|
|
771
|
+
|
|
772
|
+
| Provider | Type | Best For |
|
|
773
|
+
|----------|------|----------|
|
|
774
|
+
| `better-auth` | Database Sessions | Self-hosted apps, maximum control |
|
|
775
|
+
| `custom-db-session` | Database Sessions | Existing session tables, custom flows |
|
|
776
|
+
| `auth0` | JWT | Enterprise SSO, social login |
|
|
777
|
+
| `clerk` | JWT | Modern SaaS with prebuilt UI |
|
|
778
|
+
| `cognito` | JWT | AWS ecosystem integration |
|
|
779
|
+
| `api-key` | API Keys | Service-to-service auth, public APIs |
|
|
780
|
+
|
|
781
|
+
### Basic Configuration
|
|
782
|
+
|
|
783
|
+
Add an `auth` block to your `.apsorc`:
|
|
784
|
+
|
|
785
|
+
```json
|
|
786
|
+
{
|
|
787
|
+
"version": 2,
|
|
788
|
+
"auth": {
|
|
789
|
+
"provider": "better-auth"
|
|
790
|
+
},
|
|
791
|
+
"entities": [...]
|
|
792
|
+
}
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Run `apso server scaffold` to generate the auth guard.
|
|
796
|
+
|
|
797
|
+
### Provider-Specific Configuration
|
|
798
|
+
|
|
799
|
+
#### Better Auth / Custom DB Sessions
|
|
800
|
+
|
|
801
|
+
For database-backed session authentication:
|
|
802
|
+
|
|
803
|
+
```json
|
|
804
|
+
{
|
|
805
|
+
"auth": {
|
|
806
|
+
"provider": "better-auth",
|
|
807
|
+
"sessionEntity": "session",
|
|
808
|
+
"userEntity": "User",
|
|
809
|
+
"accountUserEntity": "AccountUser",
|
|
810
|
+
"cookiePrefix": "myapp",
|
|
811
|
+
"organizationField": "organizationId",
|
|
812
|
+
"roleField": "role"
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
| Option | Default | Description |
|
|
818
|
+
|--------|---------|-------------|
|
|
819
|
+
| `sessionEntity` | `"session"` | Entity storing session tokens |
|
|
820
|
+
| `userEntity` | `"User"` | Entity for user accounts |
|
|
821
|
+
| `accountUserEntity` | `"AccountUser"` | Junction entity for user-org mapping |
|
|
822
|
+
| `cookiePrefix` | service name | Cookie name prefix (e.g., `myapp.session_token`) |
|
|
823
|
+
| `organizationField` | `"organizationId"` | Field on accountUserEntity for org ID |
|
|
824
|
+
| `roleField` | `"role"` | Field on accountUserEntity for user role |
|
|
825
|
+
|
|
826
|
+
#### JWT Providers (Auth0, Clerk, Cognito)
|
|
827
|
+
|
|
828
|
+
For JWT-based authentication:
|
|
829
|
+
|
|
830
|
+
```json
|
|
831
|
+
{
|
|
832
|
+
"auth": {
|
|
833
|
+
"provider": "auth0",
|
|
834
|
+
"jwt": {
|
|
835
|
+
"issuer": "https://your-tenant.auth0.com/",
|
|
836
|
+
"audience": "https://your-api.example.com",
|
|
837
|
+
"jwksUri": "https://your-tenant.auth0.com/.well-known/jwks.json",
|
|
838
|
+
"algorithms": ["RS256"]
|
|
839
|
+
},
|
|
840
|
+
"claims": {
|
|
841
|
+
"userId": "sub",
|
|
842
|
+
"email": "email",
|
|
843
|
+
"organizationId": "org_id",
|
|
844
|
+
"roles": "permissions"
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
| JWT Option | Default | Description |
|
|
851
|
+
|------------|---------|-------------|
|
|
852
|
+
| `issuer` | Required | JWT issuer URL |
|
|
853
|
+
| `audience` | Required | Expected JWT audience |
|
|
854
|
+
| `jwksUri` | `issuer + /.well-known/jwks.json` | JWKS endpoint for key rotation |
|
|
855
|
+
| `algorithms` | `["RS256"]` | Accepted signing algorithms |
|
|
856
|
+
|
|
857
|
+
| Claims Option | Default | Description |
|
|
858
|
+
|---------------|---------|-------------|
|
|
859
|
+
| `userId` | `"sub"` | Claim containing user ID |
|
|
860
|
+
| `email` | `"email"` | Claim containing user email |
|
|
861
|
+
| `organizationId` | - | Claim for org/workspace ID |
|
|
862
|
+
| `roles` | `"roles"` | Claim containing role array |
|
|
863
|
+
|
|
864
|
+
#### API Key Authentication
|
|
865
|
+
|
|
866
|
+
For service-to-service or public API authentication:
|
|
867
|
+
|
|
868
|
+
```json
|
|
869
|
+
{
|
|
870
|
+
"auth": {
|
|
871
|
+
"provider": "api-key",
|
|
872
|
+
"apiKeyHeader": "x-api-key",
|
|
873
|
+
"apiKeyEntity": "ApiKey",
|
|
874
|
+
"workspaceField": "workspaceId",
|
|
875
|
+
"roleField": "permissions"
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
| Option | Default | Description |
|
|
881
|
+
|--------|---------|-------------|
|
|
882
|
+
| `apiKeyHeader` | `"x-api-key"` | Header name for API key |
|
|
883
|
+
| `apiKeyEntity` | `"ApiKey"` | Entity storing API keys |
|
|
884
|
+
| `workspaceField` | - | Field on entity for workspace ID |
|
|
885
|
+
| `roleField` | - | Field on entity for permissions |
|
|
886
|
+
|
|
887
|
+
### The AuthContext Interface
|
|
888
|
+
|
|
889
|
+
All auth providers produce a normalized `AuthContext` that's attached to every authenticated request:
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
interface AuthContext {
|
|
893
|
+
userId?: string; // The authenticated user's ID
|
|
894
|
+
email?: string; // User's email (if available)
|
|
895
|
+
workspaceId?: string; // Workspace/tenant ID
|
|
896
|
+
organizationId?: string; // Organization ID (alias)
|
|
897
|
+
roles: string[]; // User's roles/permissions
|
|
898
|
+
serviceId?: string; // For API key auth: the key identifier
|
|
899
|
+
user?: unknown; // Raw user object (provider-specific)
|
|
900
|
+
session?: unknown; // Raw session object (provider-specific)
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
This normalization is powerful - your business logic works with the same interface regardless of whether you're using Auth0 JWTs or Better Auth sessions.
|
|
905
|
+
|
|
906
|
+
### Generated Files
|
|
907
|
+
|
|
908
|
+
When `auth` is configured, `apso server scaffold` generates:
|
|
909
|
+
|
|
910
|
+
```
|
|
911
|
+
src/
|
|
912
|
+
guards/
|
|
913
|
+
auth.guard.ts # Authentication guard implementation
|
|
914
|
+
scope.guard.ts # Data scoping guard (if scopeBy used)
|
|
915
|
+
guards.module.ts # NestJS module with providers
|
|
916
|
+
index.ts # Exports
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
### Enabling Authentication
|
|
920
|
+
|
|
921
|
+
Guards are generated but **not enabled globally by default**. Enable them based on your needs:
|
|
922
|
+
|
|
923
|
+
#### Option 1: Global Enable (Recommended for most apps)
|
|
924
|
+
|
|
925
|
+
Edit `src/guards/guards.module.ts`:
|
|
926
|
+
|
|
927
|
+
```typescript
|
|
928
|
+
providers: [
|
|
929
|
+
AuthGuard,
|
|
930
|
+
ScopeGuard,
|
|
931
|
+
// Uncomment to enable globally:
|
|
932
|
+
{
|
|
933
|
+
provide: APP_GUARD,
|
|
934
|
+
useClass: AuthGuard,
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
provide: APP_GUARD,
|
|
938
|
+
useClass: ScopeGuard,
|
|
939
|
+
},
|
|
940
|
+
],
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
#### Option 2: Controller-Level Enable
|
|
944
|
+
|
|
945
|
+
```typescript
|
|
946
|
+
import { AuthGuard } from '../guards';
|
|
947
|
+
|
|
948
|
+
@UseGuards(AuthGuard)
|
|
949
|
+
@Controller('projects')
|
|
950
|
+
export class ProjectController { }
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
#### Option 3: Route-Level Enable
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
@UseGuards(AuthGuard)
|
|
957
|
+
@Get('me')
|
|
958
|
+
getProfile(@Req() req: AuthenticatedRequest) {
|
|
959
|
+
return req.auth;
|
|
960
|
+
}
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
### Decorators
|
|
964
|
+
|
|
965
|
+
```typescript
|
|
966
|
+
import { Public, SkipScopeCheck } from './guards';
|
|
967
|
+
|
|
968
|
+
// Skip ALL guards (no authentication required)
|
|
969
|
+
@Public()
|
|
970
|
+
@Get('health')
|
|
971
|
+
healthCheck() { }
|
|
972
|
+
|
|
973
|
+
// Skip only scope checking (auth still required)
|
|
974
|
+
@SkipScopeCheck()
|
|
975
|
+
@Get('admin/stats')
|
|
976
|
+
adminStats() { }
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
### Accessing Auth Context
|
|
980
|
+
|
|
981
|
+
In controllers and services, access the authenticated context:
|
|
982
|
+
|
|
983
|
+
```typescript
|
|
984
|
+
import { AuthenticatedRequest, getAuthContext, requireAuthContext } from './guards';
|
|
985
|
+
|
|
986
|
+
@Controller('projects')
|
|
987
|
+
export class ProjectController {
|
|
988
|
+
@Get()
|
|
989
|
+
findAll(@Req() req: AuthenticatedRequest) {
|
|
990
|
+
// Direct access
|
|
991
|
+
const userId = req.auth.userId;
|
|
992
|
+
const orgId = req.auth.organizationId;
|
|
993
|
+
|
|
994
|
+
// Or use helpers
|
|
995
|
+
const ctx = requireAuthContext(req); // Throws if not authenticated
|
|
996
|
+
return this.projectService.findByOrg(ctx.organizationId);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### Token Extraction
|
|
1002
|
+
|
|
1003
|
+
The generated auth guard extracts tokens from multiple locations (in order):
|
|
1004
|
+
|
|
1005
|
+
1. **Authorization header**: `Bearer <token>`
|
|
1006
|
+
2. **Cookies**: `{cookiePrefix}.session_token`, `better-auth.session_token`, or `session_token`
|
|
1007
|
+
3. **Custom header**: `X-Session-Token`
|
|
1008
|
+
|
|
1009
|
+
This flexibility supports both browser-based apps (cookies) and API clients (headers).
|
|
1010
|
+
|
|
1011
|
+
### Complete Example
|
|
1012
|
+
|
|
1013
|
+
```json
|
|
1014
|
+
{
|
|
1015
|
+
"version": 2,
|
|
1016
|
+
"auth": {
|
|
1017
|
+
"provider": "better-auth",
|
|
1018
|
+
"sessionEntity": "session",
|
|
1019
|
+
"userEntity": "User",
|
|
1020
|
+
"accountUserEntity": "AccountUser",
|
|
1021
|
+
"cookiePrefix": "myapp",
|
|
1022
|
+
"organizationField": "organizationId",
|
|
1023
|
+
"roleField": "role"
|
|
1024
|
+
},
|
|
1025
|
+
"entities": [
|
|
1026
|
+
{
|
|
1027
|
+
"name": "User",
|
|
1028
|
+
"fields": [
|
|
1029
|
+
{ "name": "email", "type": "text", "unique": true },
|
|
1030
|
+
{ "name": "name", "type": "text", "nullable": true }
|
|
1031
|
+
]
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
"name": "session",
|
|
1035
|
+
"fields": [
|
|
1036
|
+
{ "name": "token", "type": "text", "unique": true },
|
|
1037
|
+
{ "name": "expiresAt", "type": "timestamp" },
|
|
1038
|
+
{ "name": "userId", "type": "text" }
|
|
1039
|
+
]
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
"name": "Organization",
|
|
1043
|
+
"fields": [
|
|
1044
|
+
{ "name": "name", "type": "text" }
|
|
1045
|
+
]
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
"name": "AccountUser",
|
|
1049
|
+
"fields": [
|
|
1050
|
+
{ "name": "role", "type": "enum", "values": ["owner", "admin", "member"] }
|
|
1051
|
+
]
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
"name": "Project",
|
|
1055
|
+
"scopeBy": "organizationId",
|
|
1056
|
+
"fields": [
|
|
1057
|
+
{ "name": "name", "type": "text" }
|
|
1058
|
+
]
|
|
1059
|
+
}
|
|
1060
|
+
],
|
|
1061
|
+
"relationships": [
|
|
1062
|
+
{ "from": "AccountUser", "to": "User", "type": "ManyToOne" },
|
|
1063
|
+
{ "from": "AccountUser", "to": "Organization", "type": "ManyToOne" },
|
|
1064
|
+
{ "from": "Project", "to": "Organization", "type": "ManyToOne" }
|
|
1065
|
+
]
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
### Migrating Between Providers
|
|
1070
|
+
|
|
1071
|
+
One of Apso's key advantages is seamless provider migration:
|
|
1072
|
+
|
|
1073
|
+
1. Update the `auth` block in `.apsorc`
|
|
1074
|
+
2. Run `apso server scaffold`
|
|
1075
|
+
3. Update your frontend to use the new provider's login flow
|
|
1076
|
+
|
|
1077
|
+
Your business logic remains unchanged because it only interacts with the normalized `AuthContext`.
|
|
1078
|
+
|
|
1079
|
+
---
|
|
1080
|
+
|
|
749
1081
|
## Data Scoping (Multi-Tenant Isolation)
|
|
750
1082
|
|
|
751
|
-
Apso
|
|
1083
|
+
Apso provides application-layer data isolation that rivals PostgreSQL's Row-Level Security (RLS) - but with greater flexibility, visibility, and portability. This is Apso's answer to one of the most critical challenges in SaaS development: ensuring users only see and modify data they're authorized to access.
|
|
1084
|
+
|
|
1085
|
+
### Philosophy: Application-Layer RLS
|
|
1086
|
+
|
|
1087
|
+
Traditional approaches to multi-tenant data isolation include:
|
|
1088
|
+
- **Database RLS (Supabase)** - Powerful but opaque, tied to PostgreSQL, difficult to debug
|
|
1089
|
+
- **Manual filtering** - Error-prone, repetitive, easy to forget on new endpoints
|
|
1090
|
+
- **ORM middleware** - Often complex, hard to customize
|
|
1091
|
+
|
|
1092
|
+
**Apso's approach delivers the best of all worlds:**
|
|
1093
|
+
- **Declarative** - Define scope once in `.apsorc`, applied everywhere
|
|
1094
|
+
- **Transparent** - Generated guards are standard NestJS code you can inspect and debug
|
|
1095
|
+
- **Portable** - Works with any database, not locked to PostgreSQL RLS
|
|
1096
|
+
- **Flexible** - Configure per-entity behavior: auto-injection, filtering, bypass rules
|
|
1097
|
+
|
|
1098
|
+
This design is **timeless** - your data isolation logic is explicit code, not hidden database magic.
|
|
752
1099
|
|
|
753
1100
|
### What is scopeBy?
|
|
754
1101
|
|
|
@@ -961,6 +1308,168 @@ These are intentionally separate. Use `scopeBy` for data isolation, and implemen
|
|
|
961
1308
|
|
|
962
1309
|
---
|
|
963
1310
|
|
|
1311
|
+
## Authentication + Scoping: Working Together
|
|
1312
|
+
|
|
1313
|
+
Auth and Scoping are designed to work together seamlessly. This combined system delivers enterprise-grade security with minimal configuration.
|
|
1314
|
+
|
|
1315
|
+
### How They Connect
|
|
1316
|
+
|
|
1317
|
+
When both `auth` and `scopeBy` are configured:
|
|
1318
|
+
|
|
1319
|
+
1. **AuthGuard runs first** - Validates the session/token and populates `request.auth`
|
|
1320
|
+
2. **ScopeGuard runs second** - Reads `organizationId`/`workspaceId` from `request.auth` and enforces isolation
|
|
1321
|
+
|
|
1322
|
+
The `AuthContext` automatically provides the scope values that `scopeBy` needs:
|
|
1323
|
+
|
|
1324
|
+
```typescript
|
|
1325
|
+
// AuthGuard sets this on every authenticated request:
|
|
1326
|
+
request.auth = {
|
|
1327
|
+
userId: "user_123",
|
|
1328
|
+
organizationId: "org_456", // <-- ScopeGuard uses this
|
|
1329
|
+
workspaceId: "org_456", // <-- Or this (alias)
|
|
1330
|
+
roles: ["admin"],
|
|
1331
|
+
// ...
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ScopeGuard then uses organizationId to:
|
|
1335
|
+
// - Filter GET /projects -> only org_456's projects
|
|
1336
|
+
// - Inject on POST /projects -> auto-set organizationId
|
|
1337
|
+
// - Verify on GET /projects/:id -> ensure it belongs to org_456
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
### Complete Multi-Tenant Example
|
|
1341
|
+
|
|
1342
|
+
```json
|
|
1343
|
+
{
|
|
1344
|
+
"version": 2,
|
|
1345
|
+
"auth": {
|
|
1346
|
+
"provider": "better-auth",
|
|
1347
|
+
"sessionEntity": "session",
|
|
1348
|
+
"userEntity": "User",
|
|
1349
|
+
"accountUserEntity": "AccountUser",
|
|
1350
|
+
"organizationField": "organizationId"
|
|
1351
|
+
},
|
|
1352
|
+
"entities": [
|
|
1353
|
+
{
|
|
1354
|
+
"name": "User",
|
|
1355
|
+
"fields": [
|
|
1356
|
+
{ "name": "email", "type": "text", "unique": true },
|
|
1357
|
+
{ "name": "name", "type": "text", "nullable": true }
|
|
1358
|
+
]
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
"name": "session",
|
|
1362
|
+
"fields": [
|
|
1363
|
+
{ "name": "token", "type": "text", "unique": true },
|
|
1364
|
+
{ "name": "expiresAt", "type": "timestamp" },
|
|
1365
|
+
{ "name": "userId", "type": "text" }
|
|
1366
|
+
]
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
"name": "Organization",
|
|
1370
|
+
"fields": [
|
|
1371
|
+
{ "name": "name", "type": "text" },
|
|
1372
|
+
{ "name": "plan", "type": "enum", "values": ["free", "pro", "enterprise"] }
|
|
1373
|
+
]
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
"name": "AccountUser",
|
|
1377
|
+
"fields": [
|
|
1378
|
+
{ "name": "role", "type": "enum", "values": ["owner", "admin", "member"] }
|
|
1379
|
+
]
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
"name": "Project",
|
|
1383
|
+
"scopeBy": "organizationId",
|
|
1384
|
+
"fields": [
|
|
1385
|
+
{ "name": "name", "type": "text" },
|
|
1386
|
+
{ "name": "status", "type": "enum", "values": ["active", "archived"] }
|
|
1387
|
+
]
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
"name": "Task",
|
|
1391
|
+
"scopeBy": ["organizationId", "projectId"],
|
|
1392
|
+
"fields": [
|
|
1393
|
+
{ "name": "title", "type": "text" },
|
|
1394
|
+
{ "name": "completed", "type": "boolean", "default": false }
|
|
1395
|
+
]
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
"name": "AuditLog",
|
|
1399
|
+
"scopeBy": "organizationId",
|
|
1400
|
+
"scopeOptions": {
|
|
1401
|
+
"injectOnCreate": true,
|
|
1402
|
+
"enforceOn": ["find", "get"],
|
|
1403
|
+
"bypassRoles": ["superadmin"]
|
|
1404
|
+
},
|
|
1405
|
+
"fields": [
|
|
1406
|
+
{ "name": "action", "type": "text" },
|
|
1407
|
+
{ "name": "details", "type": "json" }
|
|
1408
|
+
]
|
|
1409
|
+
}
|
|
1410
|
+
],
|
|
1411
|
+
"relationships": [
|
|
1412
|
+
{ "from": "AccountUser", "to": "User", "type": "ManyToOne" },
|
|
1413
|
+
{ "from": "AccountUser", "to": "Organization", "type": "ManyToOne" },
|
|
1414
|
+
{ "from": "Project", "to": "Organization", "type": "ManyToOne" },
|
|
1415
|
+
{ "from": "Task", "to": "Project", "type": "ManyToOne" },
|
|
1416
|
+
{ "from": "Task", "to": "Organization", "type": "ManyToOne" },
|
|
1417
|
+
{ "from": "AuditLog", "to": "Organization", "type": "ManyToOne" }
|
|
1418
|
+
]
|
|
1419
|
+
}
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
### Guard Execution Order
|
|
1423
|
+
|
|
1424
|
+
Enable both guards globally for automatic protection:
|
|
1425
|
+
|
|
1426
|
+
```typescript
|
|
1427
|
+
// src/guards/guards.module.ts
|
|
1428
|
+
providers: [
|
|
1429
|
+
AuthGuard,
|
|
1430
|
+
ScopeGuard,
|
|
1431
|
+
{
|
|
1432
|
+
provide: APP_GUARD,
|
|
1433
|
+
useClass: AuthGuard, // Runs first
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
provide: APP_GUARD,
|
|
1437
|
+
useClass: ScopeGuard, // Runs second
|
|
1438
|
+
},
|
|
1439
|
+
],
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
### The Apso Security Stack
|
|
1443
|
+
|
|
1444
|
+
| Layer | Guard | Question Answered | Configuration |
|
|
1445
|
+
|-------|-------|-------------------|---------------|
|
|
1446
|
+
| 1. Identity | AuthGuard | "Who is this user?" | `auth` in `.apsorc` |
|
|
1447
|
+
| 2. Isolation | ScopeGuard | "Which data can they see?" | `scopeBy` on entities |
|
|
1448
|
+
| 3. Authorization | (Your implementation) | "What actions can they take?" | Custom RBAC guard |
|
|
1449
|
+
|
|
1450
|
+
Apso handles layers 1 and 2 automatically. Layer 3 (fine-grained permissions like "can edit this specific resource") is left to your business logic since it varies widely between applications.
|
|
1451
|
+
|
|
1452
|
+
### Why This Matters: The Supabase Comparison
|
|
1453
|
+
|
|
1454
|
+
| Feature | Supabase | Apso |
|
|
1455
|
+
|---------|----------|------|
|
|
1456
|
+
| **Auth** | Built-in, proprietary | Bring your own, code you own |
|
|
1457
|
+
| **Data Isolation** | PostgreSQL RLS (opaque) | Application-layer guards (transparent) |
|
|
1458
|
+
| **Portability** | Locked to Supabase | Works with any database |
|
|
1459
|
+
| **Debugging** | Database logs, hard to trace | Standard NestJS code, full visibility |
|
|
1460
|
+
| **Customization** | Limited to RLS policies | Unlimited - it's your code |
|
|
1461
|
+
| **Migration Path** | Rewrite required | Change config, regenerate |
|
|
1462
|
+
|
|
1463
|
+
Apso delivers equivalent functionality to Supabase's auth + RLS combo, but with:
|
|
1464
|
+
- **Full code ownership** - No vendor lock-in
|
|
1465
|
+
- **Provider flexibility** - Auth0, Clerk, Cognito, or self-hosted
|
|
1466
|
+
- **Database freedom** - PostgreSQL, MySQL, MongoDB, or any TypeORM-supported database
|
|
1467
|
+
- **Complete transparency** - Debug with standard tools, not vendor-specific dashboards
|
|
1468
|
+
|
|
1469
|
+
This architecture is **timeless** - it grows with your needs, migrates with your stack, and remains fully under your control.
|
|
1470
|
+
|
|
1471
|
+
---
|
|
1472
|
+
|
|
964
1473
|
## Schema Reference
|
|
965
1474
|
|
|
966
1475
|
The `.apsorc` file must conform to the [APSO Configuration Schema](./apsorc.schema.json). This schema defines all valid properties, types, and constraints for your configuration file.
|
|
@@ -72,7 +72,7 @@ class New extends base_command_1.default {
|
|
|
72
72
|
let projectName = flags.name;
|
|
73
73
|
let apiType = (_a = flags.type) === null || _a === void 0 ? void 0 : _a.toLowerCase();
|
|
74
74
|
if (!projectName) {
|
|
75
|
-
projectName =
|
|
75
|
+
projectName = "";
|
|
76
76
|
}
|
|
77
77
|
if (!apiType) {
|
|
78
78
|
apiType = "rest";
|
|
@@ -10,7 +10,7 @@ const perf_hooks_1 = require("perf_hooks");
|
|
|
10
10
|
const Eta = tslib_1.__importStar(require("eta"));
|
|
11
11
|
Eta.configure({
|
|
12
12
|
views: path.join(__dirname, "../../lib/templates"),
|
|
13
|
-
cache: false
|
|
13
|
+
cache: false,
|
|
14
14
|
});
|
|
15
15
|
class Scaffold extends base_command_1.default {
|
|
16
16
|
async scaffoldServer(options) {
|
|
@@ -25,7 +25,7 @@ class Scaffold extends base_command_1.default {
|
|
|
25
25
|
entity,
|
|
26
26
|
relationships: entityRelationships,
|
|
27
27
|
apiType,
|
|
28
|
-
allEntities
|
|
28
|
+
allEntities,
|
|
29
29
|
});
|
|
30
30
|
switch (apiType) {
|
|
31
31
|
case apsorc_parser_1.ApiType.Graphql:
|
|
@@ -45,18 +45,25 @@ class Scaffold extends base_command_1.default {
|
|
|
45
45
|
const filePath = path.join(dir, entity.name);
|
|
46
46
|
const entityRelationships = relationshipMap[entity.name] || [];
|
|
47
47
|
const tDto = perf_hooks_1.performance.now();
|
|
48
|
-
await (0, lib_1.createDto)(filePath, entity, entityRelationships, {
|
|
48
|
+
await (0, lib_1.createDto)(filePath, entity, entityRelationships, {
|
|
49
|
+
apiType,
|
|
50
|
+
allEntities,
|
|
51
|
+
});
|
|
49
52
|
if (process.env.DEBUG) {
|
|
50
53
|
console.log(`[timing] createDto for ${entity.name}: ${(perf_hooks_1.performance.now() - tDto).toFixed(2)}ms`);
|
|
51
54
|
const used = process.memoryUsage();
|
|
52
|
-
console.log(`[mem] heapUsed after createDto for ${entity.name}: ${(used.heapUsed /
|
|
55
|
+
console.log(`[mem] heapUsed after createDto for ${entity.name}: ${(used.heapUsed /
|
|
56
|
+
1024 /
|
|
57
|
+
1024).toFixed(2)} MB`);
|
|
53
58
|
}
|
|
54
59
|
const tService = perf_hooks_1.performance.now();
|
|
55
60
|
await (0, lib_1.createService)(filePath, entity, relationshipMap);
|
|
56
61
|
if (process.env.DEBUG) {
|
|
57
62
|
console.log(`[timing] createService for ${entity.name}: ${(perf_hooks_1.performance.now() - tService).toFixed(2)}ms`);
|
|
58
63
|
const used = process.memoryUsage();
|
|
59
|
-
console.log(`[mem] heapUsed after createService for ${entity.name}: ${(used.heapUsed /
|
|
64
|
+
console.log(`[mem] heapUsed after createService for ${entity.name}: ${(used.heapUsed /
|
|
65
|
+
1024 /
|
|
66
|
+
1024).toFixed(2)} MB`);
|
|
60
67
|
}
|
|
61
68
|
const tController = perf_hooks_1.performance.now();
|
|
62
69
|
await (0, lib_1.createController)(filePath, entity, relationshipMap);
|
|
@@ -71,7 +78,7 @@ class Scaffold extends base_command_1.default {
|
|
|
71
78
|
}
|
|
72
79
|
async run() {
|
|
73
80
|
const totalBuildStart = perf_hooks_1.performance.now();
|
|
74
|
-
const { rootFolder, entities, relationshipMap, apiType } = (0, lib_1.parseApsorc)();
|
|
81
|
+
const { rootFolder, entities, relationshipMap, apiType, auth } = (0, lib_1.parseApsorc)();
|
|
75
82
|
const rootPath = path.join(process.cwd(), rootFolder);
|
|
76
83
|
const autogenPath = path.join(rootPath, "autogen");
|
|
77
84
|
const lowerCaseApiType = apiType.toLowerCase();
|
|
@@ -83,15 +90,17 @@ class Scaffold extends base_command_1.default {
|
|
|
83
90
|
entity,
|
|
84
91
|
relationshipMap,
|
|
85
92
|
apiType: lowerCaseApiType,
|
|
86
|
-
allEntities: entities
|
|
93
|
+
allEntities: entities,
|
|
87
94
|
});
|
|
88
95
|
if (process.env.DEBUG) {
|
|
89
96
|
const used = process.memoryUsage();
|
|
90
|
-
console.log(`[mem] heapUsed after ${entity.name}: ${(used.heapUsed /
|
|
97
|
+
console.log(`[mem] heapUsed after ${entity.name}: ${(used.heapUsed /
|
|
98
|
+
1024 /
|
|
99
|
+
1024).toFixed(2)} MB`);
|
|
91
100
|
}
|
|
92
101
|
}));
|
|
93
|
-
// Generate
|
|
94
|
-
await (0, lib_1.createGuards)(rootPath, entities);
|
|
102
|
+
// Generate guards (auth and/or scope) based on .apsorc configuration
|
|
103
|
+
await (0, lib_1.createGuards)(rootPath, entities, auth);
|
|
95
104
|
await (0, lib_1.createIndexAppModule)(autogenPath, entities, lowerCaseApiType);
|
|
96
105
|
const totalBuildTime = perf_hooks_1.performance.now() - totalBuildStart;
|
|
97
106
|
console.log(`[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Entity } from "./types/entity";
|
|
2
2
|
import { ApsorcRelationship, RelationshipMap } from "./types/relationship";
|
|
3
|
+
import { AuthConfig } from "./types/auth";
|
|
3
4
|
export declare enum ApiType {
|
|
4
5
|
Graphql = "graphql",
|
|
5
6
|
Rest = "rest"
|
|
@@ -10,6 +11,7 @@ export type ApsorcType = {
|
|
|
10
11
|
entities: Entity[];
|
|
11
12
|
apiType: ApiType;
|
|
12
13
|
relationships: ApsorcRelationship[];
|
|
14
|
+
auth?: AuthConfig;
|
|
13
15
|
};
|
|
14
16
|
type ParsedApsorcData = {
|
|
15
17
|
entities: Entity[];
|
|
@@ -20,6 +22,7 @@ type ParsedApsorc = {
|
|
|
20
22
|
apiType: string;
|
|
21
23
|
entities: Entity[];
|
|
22
24
|
relationshipMap: RelationshipMap;
|
|
25
|
+
auth?: AuthConfig;
|
|
23
26
|
};
|
|
24
27
|
export declare const parseApsorcV1: (apsorc: ApsorcType) => ParsedApsorcData;
|
|
25
28
|
export declare const parseApsorcV2: (apsorc: ApsorcType) => ParsedApsorcData;
|