     1	import { RestApiResourceError } from '../../lib/rest-api-errors.js'
     2	
     3	export const AccessPlugin = {
     4	  name: 'rest-api-access',
     5	  dependencies: ['rest-api'],
     6	
     7	  async install ({ addHook, helpers, log, pluginOptions = {}, scopes }) {
     8	    const ownershipOptions = pluginOptions.ownership || pluginOptions.autoOwnership || {}
     9	
    10	    const config = {
    11	      ownership: {
    12	        enabled: ownershipOptions.enabled !== false,
    13	        field: ownershipOptions.field || 'user_id',
    14	        userResource: ownershipOptions.userResource || 'users',
    15	        excludeResources: ownershipOptions.excludeResources || [],
    16	        filterByOwner: ownershipOptions.filterByOwner !== false,
    17	        requireOwnership: ownershipOptions.requireOwnership || false
    18	      }
    19	    }
    20	
    21	    const state = {
    22	      authCheckers: new Map()
    23	    }
    24	
    25	    const ownershipField = () => config.ownership.field
    26	
    27	    // Minimal helper: treat ownerField false/null as ownership disabled (alias)
    28	    const isOwnershipDisabled = (scopeOptions = {}) => (
    29	      scopeOptions?.ownership === false ||
    30	      scopeOptions?.ownerField === false ||
    31	      scopeOptions?.ownerField === null
    32	    )
    33	
    34	    const evaluateOwnership = ({ record, field, schemaInfo, userId }) => {
    35	      if (!record) return 'unknown'
    36	
    37	      const idProperty = schemaInfo?.idProperty || 'id'
    38	      const schemaStructure = schemaInfo?.schemaStructure || {}
    39	      const schemaRelationships = schemaInfo?.schemaRelationships || {}
    40	
    41	      const matchesUser = (value) => value !== undefined && value !== null && String(value) === userId
    42	
    43	      if (field === idProperty) {
    44	        if (record.id === undefined || record.id === null) {
    45	          return 'unknown'
    46	        }
    47	        return matchesUser(record.id) ? 'match' : 'mismatch'
    48	      }
    49	
    50	      const fieldSchema = schemaStructure[field]
    51	
    52	      if (record.type && record.attributes && fieldSchema && !fieldSchema.belongsTo) {
    53	        const attributeValue = record.attributes[field]
    54	        if (attributeValue !== undefined && attributeValue !== null) {
    55	          return matchesUser(attributeValue) ? 'match' : 'mismatch'
    56	        }
    57	      }
    58	
    59	      let relationshipName
    60	      if (fieldSchema?.belongsTo) {
    61	        relationshipName = fieldSchema.as || field
    62	      } else if (schemaRelationships[field]) {
    63	        relationshipName = field
    64	      }
    65	
    66	      if (!relationshipName) {
    67	        return 'unknown'
    68	      }
    69	
    70	      const relationship = record.relationships?.[relationshipName]
    71	      const relData = relationship?.data
    72	      if (!relData) {
    73	        return 'unknown'
    74	      }
    75	
    76	      if (Array.isArray(relData)) {
    77	        return relData.some((item) => matchesUser(item?.id)) ? 'match' : 'mismatch'
    78	      }
    79	
    80	      if (relData?.id === undefined || relData?.id === null) {
    81	        return 'unknown'
    82	      }
    83	
    84	      return matchesUser(relData.id) ? 'match' : 'mismatch'
    85	    }
    86	
    87	    const resolveStorageAdapter = (scopeName, hookContext = {}) => {
    88	      if (!scopeName) return null
    89	
    90	      if (hookContext.storageAdapter) return hookContext.storageAdapter
    91	      if (hookContext.knexQuery?.storageAdapter) return hookContext.knexQuery.storageAdapter
    92	
    93	      if (helpers.getStorageAdapter) {
    94	        return helpers.getStorageAdapter(scopeName)
    95	      }
    96	      return null
    97	    }
    98	
    99	    const translateColumnReference = ({ field, tableName, scopeName, hookContext }) => {
   100	      const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
   101	      const translated = storageAdapter?.translateColumn?.(field) ?? field
   102	      if (tableName) {
   103	        return `${tableName}.${translated}`
   104	      }
   105	      return translated
   106	    }
   107	
   108	    const translateFilterValue = ({ field, value, scopeName, hookContext }) => {
   109	      const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
   110	      if (!storageAdapter?.translateFilterValue) return value
   111	      return storageAdapter.translateFilterValue(field, value)
   112	    }
   113	
   114	    function registerBuiltinCheckers () {
   115	      state.authCheckers.set('public', () => true)
   116	
   117	      state.authCheckers.set('authenticated', (context) => {
   118	        if (context.auth?.system === true) return true
   119	        return !!(context.auth?.userId || context.auth?.providerId)
   120	      })
   121	
   122	      state.authCheckers.set('owns', (context, { existingRecord, scopeVars }) => {
   123	        if (context.auth?.system === true) return true
   124	        if (!context.auth?.userId) return false
   125	        if (context.method === 'post') return true
   126	
   127	        // Minimal record is always prepared upstream (dataGetMinimal for reads, request payload snapshot for POST).
   128	        const record = existingRecord
   129	        if (!record) return true
   130	
   131	        const userId = String(context.auth.userId)
   132	        const field = scopeVars?.ownershipField || ownershipField()
   133	        const schemaInfo = scopeVars?.schemaInfo || {}
   134	
   135	        const ownershipStatus = evaluateOwnership({
   136	          record,
   137	          field,
   138	          schemaInfo,
   139	          userId
   140	        })
   141	
   142	        return ownershipStatus === 'match'
   143	      })
   144	    }
   145	
   146	    registerBuiltinCheckers()
   147	
   148	    addHook('scope:added', 'rest-auth-process-rules', {}, ({ context, scopes }) => {
   149	      const { scopeName } = context
   150	      const scope = scopes[scopeName]
   151	
   152	      const auth = context.scopeOptions?.auth
   153	      if (!auth) return
   154	
   155	      scope.vars.authRules = auth
   156	      scope.vars.ownershipField = context.scopeOptions?.ownershipField
   157	
   158	      log?.info?.(`Auth rules registered for ${scopeName}`, {
   159	        query: auth.query,
   160	        get: auth.get,
   161	        post: auth.post,
   162	        patch: auth.patch,
   163	        delete: auth.delete
   164	      })
   165	    })
   166	
   167	    addHook('schema:enrich', 'rest-auth-auto-ownership-field', {}, ({ context }) => {
   168	      if (!config.ownership.enabled) return
   169	      const { fields, scopeName, scopeOptions = {} } = context
   170	      if (config.ownership.excludeResources.includes(scopeName)) return
   171	      if (isOwnershipDisabled(scopeOptions)) return
   172	
   173	      const field = ownershipField()
   174	      const relationshipName = fields[field]?.as || fields[field]?.belongsTo || field
   175	
   176	      if (fields[field]) {
   177	        if (!fields[field].belongsTo) {
   178	          fields[field].belongsTo = config.ownership.userResource
   179	        }
   180	        if (!fields[field].as) {
   181	          fields[field].as = relationshipName
   182	        }
   183	        return
   184	      }
   185	
   186	      fields[field] = {
   187	        type: 'id',
   188	        belongsTo: config.ownership.userResource,
   189	        ...(relationshipName ? { as: relationshipName } : {}),
   190	        nullable: true,
   191	        indexed: true,
   192	        description: 'Automatically managed ownership field'
   193	      }
   194	    })
   195	
   196	    function setOwnerOnInput ({ context, scopeName, scopes, scopeOptions }) {
   197	      if (!config.ownership.enabled) return
   198	      if (config.ownership.excludeResources.includes(scopeName)) return
   199	      if (!context.auth?.userId) {
   200	        if (config.ownership.requireOwnership) {
   201	          throw new Error(`Cannot operate on ${scopeName} without authentication`)
   202	        }
   203	        return
   204	      }
   205	
   206	      if (isOwnershipDisabled(scopeOptions)) return
   207	
   208	      const field = ownershipField()
   209	      const scope = scopes[scopeName]
   210	      const schemaInfo = scope?.vars?.schemaInfo
   211	      const fieldSchema = schemaInfo?.schemaStructure?.[field]
   212	      const hasField = !!fieldSchema
   213	      const shouldSet = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
   214	      if (!shouldSet || !hasField) return
   215	
   216	      if (context.auth.roles?.includes?.('admin')) {
   217	        return
   218	      }
   219	
   220	      if (!context.inputRecord?.data) return
   221	
   222	      const relationships = context.inputRecord.data.relationships = context.inputRecord.data.relationships || {}
   223	      const attributes = context.inputRecord.data.attributes = context.inputRecord.data.attributes || {}
   224	
   225	      const relationshipName = fieldSchema.as || fieldSchema.belongsTo || field
   226	
   227	      if (fieldSchema.belongsTo) {
   228	        relationships[relationshipName] = {
   229	          data: {
   230	            type: fieldSchema.belongsTo || config.ownership.userResource || 'users',
   231	            id: String(context.auth.userId)
   232	          }
   233	        }
   234	      } else {
   235	        attributes[field] = context.auth.userId
   236	      }
   237	    }
   238	
   239	    addHook('beforeProcessingPost', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
   240	      setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
   241	    })
   242	    addHook('beforeProcessingPatch', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
   243	      setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
   244	    })
   245	    addHook('beforeProcessingPut', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
   246	      setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
   247	    })
   248	
   249	    addHook('knexQueryFiltering', 'rest-auth-filter-by-owner', { sequence: -40 }, ({ context, scopes, scopeOptions }) => {
   250	      if (!config.ownership.enabled || !config.ownership.filterByOwner) return
   251	
   252	      const { query, tableName, scopeName } = context.knexQuery || {}
   253	
   254	      if (!query || !tableName) {
   255	        throw new Error('AccessPlugin: knexQuery must provide query and tableName for ownership filtering')
   256	      }
   257	
   258	      if (config.ownership.excludeResources.includes(scopeName)) return
   259	      if (!context.auth?.userId) {
   260	        if (config.ownership.requireOwnership) {
   261	          throw new Error(`Cannot query ${scopeName} without authentication`)
   262	        }
   263	        return
   264	      }
   265	
   266	      if (context.auth.roles?.includes?.('admin')) return
   267	      if (isOwnershipDisabled(scopeOptions)) return
   268	
   269	      const field = ownershipField()
   270	      const scope = scopes[scopeName]
   271	      const schemaInfo = scope?.vars?.schemaInfo
   272	      const hasField = !!schemaInfo?.schemaStructure?.[field]
   273	      const shouldFilter = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
   274	      if (!shouldFilter || !hasField) return
   275	
   276	      const storageAdapter = resolveStorageAdapter(scopeName, context)
   277	      if (storageAdapter && !context.storageAdapter) {
   278	        context.storageAdapter = storageAdapter
   279	      }
   280	
   281	      const columnRef = translateColumnReference({ field, tableName, scopeName, hookContext: context })
   282	      const ownerValue = translateFilterValue({ field, value: context.auth.userId, scopeName, hookContext: context })
   283	
   284	      if (ownerValue === null) {
   285	        query.whereNull(columnRef)
   286	      } else {
   287	        query.where(columnRef, ownerValue)
   288	      }
   289	    })
   290	
   291	    addHook('checkPermissions', 'rest-auth-enforce', { sequence: -100 }, async ({ context, scope, scopeName }) => {
   292	      const operation = context.method
   293	      const scopeVars = scope?.vars
   294	      const authRules = scopeVars?.authRules
   295	      const minimalRecord = context.originalContext?.minimalRecord
   296	
   297	      if (!authRules) return
   298	      const rules = authRules[operation]
   299	      if (!rules) {
   300	        throw new Error(`Operation '${operation}' not allowed on resource '${scopeName}'`)
   301	      }
   302	
   303	      let passed = false
   304	      const failures = []
   305	
   306	      for (const rule of rules) {
   307	        try {
   308	          const [checkerName, ...paramParts] = rule.split(':')
   309	          const checker = state.authCheckers.get(checkerName)
   310	          const param = paramParts.join(':') || undefined
   311	          if (!checker) {
   312	            throw new Error(`Unknown auth rule: ${rule}`)
   313	          }
   314	
   315	          const result = await checker(context.originalContext || context, {
   316	            existingRecord: minimalRecord,
   317	            scopeVars,
   318	            param
   319	          })
   320	
   321	          if (result) {
   322	            passed = true
   323	            break
   324	          }
   325	
   326	          failures.push(rule)
   327	        } catch (error) {
   328	          failures.push(`${rule} (error: ${error.message})`)
   329	        }
   330	      }
   331	
   332	      if (!passed) {
   333	        const err = new Error(
   334	          `Access denied. Required one of: ${rules.join(', ')}. Failed checks: ${failures.join(', ')}`
   335	        )
   336	        err.statusCode = 403
   337	        throw err
   338	      }
   339	
   340	      context.authGranted = true
   341	    })
   342	
   343	    addHook('checkPermissions', 'rest-auth-check-get-ownership', { sequence: -80 }, ({ context, scope, scopeName, scopeOptions }) => {
   344	      if (!config.ownership.enabled || !config.ownership.filterByOwner) return
   345	      if (config.ownership.excludeResources.includes(scopeName)) return
   346	
   347	      if (!['get', 'put', 'patch', 'delete'].includes(context.method)) return
   348	
   349	      const auth = context.originalContext?.auth || context.auth
   350	      if (!auth?.userId) return
   351	      if (auth.roles?.includes?.('admin')) return
   352	      if (isOwnershipDisabled(scopeOptions)) return
   353	
   354	      const field = ownershipField()
   355	      const schemaInfo = scope?.vars?.schemaInfo || {}
   356	      const hasField = !!schemaInfo?.schemaStructure?.[field]
   357	      const shouldCheck = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
   358	      if (!shouldCheck) return
   359	
   360	      const record = context.originalContext?.minimalRecord
   361	      if (!record) return
   362	
   363	      const idProperty = schemaInfo.idProperty || 'id'
   364	      const userId = String(auth.userId)
   365	
   366	      // Ownership by primary key (e.g., users modifying themselves)
   367	      if (field === idProperty) {
   368	        if (record.id !== undefined && record.id !== null && String(record.id) !== userId) {
   369	          throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
   370	        }
   371	        return
   372	      }
   373	
   374	      const ownershipStatus = evaluateOwnership({
   375	        record,
   376	        field,
   377	        schemaInfo,
   378	        userId
   379	      })
   380	
   381	      if (ownershipStatus === 'mismatch') {
   382	        throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
   383	      }
   384	    })
   385	
   386	    if (!helpers.auth) {
   387	      helpers.auth = {}
   388	    }
   389	
   390	    helpers.auth.requireAuth = function requireAuth (context) {
   391	      if (!context.auth?.userId) {
   392	        const error = new Error('Authentication required')
   393	        error.statusCode = 401
   394	        throw error
   395	      }
   396	      return context.auth
   397	    }
   398	
   399	    helpers.auth.requireOwnership = function requireOwnership (context, resourceOrUserId) {
   400	      const auth = helpers.auth.requireAuth(context)
   401	      const field = ownershipField()
   402	
   403	      let ownerId
   404	
   405	      if (typeof resourceOrUserId === 'object' && resourceOrUserId !== null) {
   406	        ownerId = resourceOrUserId[field]
   407	        if (!ownerId) {
   408	          throw new Error(`Resource does not have ownership field '${field}'`)
   409	        }
   410	      } else if (resourceOrUserId !== undefined) {
   411	        ownerId = resourceOrUserId
   412	      } else if (context.existingRecord) {
   413	        ownerId = context.existingRecord[field]
   414	        if (!ownerId) {
   415	          throw new Error(`Resource does not have ownership field '${field}'`)
   416	        }
   417	      } else {
   418	        throw new Error('No resource or user ID provided for ownership check')
   419	      }
   420	
   421	      if (String(auth.userId) !== String(ownerId)) {
   422	        const error = new Error('Access denied: you do not own this resource')
   423	        error.statusCode = 403
   424	        throw error
   425	      }
   426	
   427	      return auth
   428	    }
   429	
   430	    helpers.auth.registerChecker = function registerChecker (name, checkerFn) {
   431	      state.authCheckers.set(name, checkerFn)
   432	    }
   433	
   434	    helpers.auth.checkPermission = async function checkPermission (context, rules, options = {}) {
   435	      if (!rules || rules.length === 0) return true
   436	
   437	      const { existingRecord, scopeVars } = options
   438	
   439	      for (const rule of rules) {
   440	        const [checkerName, ...paramParts] = rule.split(':')
   441	        const checker = state.authCheckers.get(checkerName)
   442	        if (!checker) continue
   443	
   444	        const param = paramParts.join(':') || undefined
   445	        if (await checker(context, { existingRecord, scopeVars, param })) {
   446	          return true
   447	        }
   448	      }
   449	
   450	      return false
   451	    }
   452	
   453	    helpers.auth.cleanup = function cleanup () {
   454	      state.authCheckers.clear()
   455	    }
   456	  }
   457	}
   458	
   459	export default AccessPlugin
